Форум программистов, компьютерный форум, киберфорум
stackOverflow
Войти
Регистрация
Восстановить пароль

Passkey в ASP.NET Core identity

Запись от stackOverflow размещена 29.10.2025 в 15:40
Показов 3666 Комментарии 0

Нажмите на изображение для увеличения
Название: Passkey в ASP.NET Core identity.jpg
Просмотров: 163
Размер:	196.2 Кб
ID:	11350
Пароли мертвы. Нет, серьезно - я повторяю это уже лет пять, но теперь впервые за это время чувствую, что это не просто красивые слова. В .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 разработчиком?
Попалось хор краткое обзорное видео 2016 года с таким названием - Что нужно знать, чтобы стать...

Какая разница между ASP .Net Core и ASP .Net Core MVC?
Какая разница между ASP .Net Core и ASP .Net Core MVC? Или я может что-то не так понял? И...


Технология WebAuthn и FIDO2 как основа Passkey



Нажмите на изображение для увеличения
Название: Passkey в ASP.NET Core identity 2.jpg
Просмотров: 68
Размер:	113.5 Кб
ID:	11351

WebAuthn - это браузерный 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), счетчик и расширения.

Вот тут начинается магия криптографии. Подпись создается по формуле:

https://www.cyberforum.ru/cgi-bin/latex.cgi?Signature = Sign(PrivateKey, SHA256(AuthenticatorData || ClientDataHash))

Где 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 версии 10



Microsoft проделала неплохую работу, интегрировав Passkey прямо в ядро Identity. Раньше приходилось подключать Fido2NetLib, разбираться с десятками классов и методов, тестировать каждый кейс отдельно. Теперь основной функционал работает из коробки, хотя с оговорками - но об этом позже.

Начну с того, что поддержка Passkey появилась в .NET 10 preview 6, и это важно понимать. Код еще менялся от превью к превью, и к финальному релизу может измениться еще. Я тестировал на preview 7, и там уже успели переименовать несколько ключевых методов. ConfigurePasskeyCreationOptionsAsync превратился в MakePasskeyCreationOptionsAsync - мелочь, но ломает код, если обновляешься.

Архитектурно всё строится вокруг расширений SignInManager<TUser>. Добавили два основных метода: один для создания опций регистрации Passkey, другой для опций аутентификации. Плюс методы для верификации - PerformPasskeyAttestationAsync и собственно сам логин через Passkey. API получился довольно лаконичным, хотя местами чувствуется, что команда пыталась сохранить обратную совместимость с существующим Identity.

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
public class PasskeyController : Controller
{
    private readonly UserManager<ApplicationUser> _userManager;
    private readonly SignInManager<ApplicationUser> _signInManager;
 
    // Генерируем опции для создания нового Passkey
    [HttpPost]
    public async Task<IActionResult> CreatePasskeyOptions()
    {
        var user = await _userManager.GetUserAsync(User);
        if (user == null) return Unauthorized();
 
        // Создаем сущность пользователя для Passkey
        var userId = await _userManager.GetUserIdAsync(user);
        var userName = await _userManager.GetUserNameAsync(user) ?? "Unknown";
        
        var passkeyUser = new PasskeyUserEntity(
            userId, 
            userName, 
            displayName: userName
        );
 
        var args = new PasskeyCreationArgs(passkeyUser);
        
        // Генерируем опции и сохраняем их в cookie
        var options = await _signInManager.MakePasskeyCreationOptionsAsync(args);
        
        // Возвращаем JSON для клиента
        return Content(options.AsJson(), "application/json");
    }
}
Метод MakePasskeyCreationOptionsAsync под капотом делает несколько вещей. Генерирует случайный challenge - обычно 32 байта из криптографически стойкого генератора. Собирает информацию о поддерживаемых алгоритмах - по умолчанию это ES256, RS256 и куча других, которые мало кто использует. Формирует список уже зарегистрированных credentials для исключения дубликатов. И самое важное - сохраняет эти опции в зашифрованном authentication cookie, чтобы потом проверить, что ответ от клиента соответствует тому, что мы отправили.
JSON, который улетает клиенту, выглядит примерно так:

JSON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
{
  "rp": {
    "name": "MyApp",
    "id": "localhost"
  },
  "user": {
    "id": "MTIzNDU2Nzg5MA==",
    "name": "user@example.com",
    "displayName": "user@example.com"
  },
  "challenge": "dGVzdGNoYWxsZW5nZQ==",
  "pubKeyCredParams": [
    {"type": "public-key", "alg": -7},
    {"type": "public-key", "alg": -257}
  ],
  "timeout": 60000,
  "attestation": "none"
}
Обрати внимание на attestation: "none". Microsoft по умолчанию не запрашивает аттестацию устройства. Это сознательное решение - аттестация может раскрывать информацию о том, какой именно аутентификатор используется, что некоторые считают нарушением приватности. Если нужна аттестация для корпоративных политик, придется использовать extensibility points и подключать стороннюю библиотеку для валидации сертификатов.

После того как браузер создал Passkey и вернул credentials, нужно их проверить и сохранить. Тут в игру вступает PerformPasskeyAttestationAsync:

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
[HttpPost]
public async Task<IActionResult> RegisterPasskey([FromForm] PasskeyRegistrationInput input)
{
    var user = await _userManager.GetUserAsync(User);
    if (user == null) return Unauthorized();
 
    // Достаем опции из cookie, которые сохранили ранее
    var options = await _signInManager.RetrievePasskeyCreationOptionsAsync();
    if (options == null) 
        return BadRequest("Опции регистрации не найдены или истекли");
 
    // Проверяем корректность полученных credentials
    var result = await _signInManager.PerformPasskeyAttestationAsync(
        input.CredentialJson, 
        options
    );
 
    if (!result.Succeeded)
        return BadRequest($"Ошибка валидации: {result.Failure?.Message}");
 
    // Сохраняем Passkey в аккаунт пользователя
    var setResult = await _userManager.SetPasskeyAsync(user, result.Passkey);
    if (!setResult.Succeeded)
        return BadRequest("Не удалось сохранить Passkey");
 
    return Ok();
}
PerformPasskeyAttestationAsync парсит JSON от клиента, проверяет подпись, валидирует origin и challenge, убеждается что счетчик подписей установлен в ноль (это новый ключ), проверяет формат credential ID. Если всё в порядке, возвращает объект Passkey с извлеченным открытым ключом и метаданными.
Хранение в базе данных реализовано через новую таблицу AspNetUserPasskeys. В EF Core миграция выглядит минималистично:

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
migrationBuilder.CreateTable(
    name: "AspNetUserPasskeys",
    columns: table => new
    {
        CredentialId = table.Column<byte[]>(maxLength: 1024, nullable: false),
        UserId = table.Column<string>(nullable: false),
        Data = table.Column<string>(nullable: false)
    },
    constraints: table =>
    {
        table.PrimaryKey("PK_AspNetUserPasskeys", x => x.CredentialId);
        table.ForeignKey(
            name: "FK_AspNetUserPasskeys_AspNetUsers_UserId",
            column: x => x.UserId,
            principalTable: "AspNetUsers",
            principalColumn: "Id",
            onDelete: ReferentialAction.Cascade);
    });
Видишь колонку Data? Это JSON с открытым ключом, алгоритмом, счетчиком и прочими техническими деталями. В preview 6 было больше колонок - хранили всё раздельно - но команда упростила схему. Мне такое решение кажется спорным. С одной стороны, проще миграции. С другой - нельзя сделать индекс по алгоритму или счетчику, если вдруг понадобится выборка. Cascade delete настроен правильно - удаляешь пользователя, автоматом удаляются все его Passkey. Но нет никакой истории изменений. Если пользователь удалил Passkey случайно, восстановить нельзя. В enterprise системах я бы добавил soft delete с полем IsDeleted и таймстампом. Но в базовом шаблоне такого нет.

Интеграция с существующей моделью IdentityUser происходит через extension методы. Сама модель не меняется - нет новых свойств или навигационных коллекций. Passkey живут отдельно, связываясь только через UserId. Это хорошо с точки зрения обратной совместимости, но создает разрыв - нельзя просто сделать user.Passkeys.ToList(), приходится лезть через UserManager.

C#
1
2
3
4
5
6
7
8
// Получить все Passkey пользователя
var passkeys = await _userManager.GetPasskeysAsync(user);
 
// Удалить конкретный Passkey
await _userManager.RemovePasskeyAsync(user, credentialId);
 
// Переименовать Passkey (да, есть и такое)
await _userManager.SetPasskeyNameAsync(user, credentialId, newName);
Имя Passkey - это чисто UI фича. Пользователь может назвать свой ключ "Рабочий ноутбук" или "iPhone", чтобы потом различать их в списке. Технически имя хранится в том же JSON в колонке Data, не влияет на криптографию никак.

Конфигурация параметров Passkey происходит через IdentityOptions, хотя настроек там минимум. Можно задать timeout для операций - по умолчанию 60 секунд, что разумно. Можно указать список разрешенных алгоритмов подписи, хотя зачем ограничивать ES256 и RS256, если они оба безопасны, не совсем понятно. Серверный endpoint для генерации challenge задается при регистрации маршрутов.

C#
1
2
3
4
5
6
7
8
services.AddIdentity<ApplicationUser, IdentityRole>(options =>
{
    // Эти настройки влияют и на Passkey тоже
    options.SignIn.RequireConfirmedAccount = true;
    options.User.RequireUniqueEmail = true;
})
.AddEntityFrameworkStores<ApplicationDbContext>()
.AddDefaultTokenProviders();
Заметь - специальных настроек для Passkey нет. Всё работает поверх существующей конфигурации Identity. Это и плюс, и минус одновременно. Плюс - простая миграция. Минус - нет гибкости. Например, нельзя отдельно настроить политики для Passkey и обычных паролей. Либо требуем подтверждение email для всех, либо ни для кого.

Обработка ошибок - слабое место текущей реализации. Методы возвращают IdentityResult или специализированные результаты типа PasskeyAttestationResult, но сообщения об ошибках часто неинформативны. "Attestation verification failed" - окей, но почему? Challenge не совпал? Origin неправильный? Подпись битая? Приходится лезть в исходники Identity, чтобы понять, что именно пошло не так.
Я делал обертку над стандартными методами, которая логирует детальную информацию при ошибках:

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
public class PasskeyService
{
    private readonly SignInManager<ApplicationUser> _signInManager;
    private readonly ILogger<PasskeyService> _logger;
 
    public async Task<(bool Success, string? Error)> VerifyPasskeyAsync(
        string credentialJson, 
        PasskeyCreationOptions options)
    {
        try
        {
            var result = await _signInManager.PerformPasskeyAttestationAsync(
                credentialJson, 
                options
            );
 
            if (!result.Succeeded)
            {
                // Детально логируем причину провала
                _logger.LogWarning(
                    "Passkey verification failed: {Reason}. Credential: {Cred}", 
                    result.Failure?.Message,
                    credentialJson.Substring(0, Math.Min(100, credentialJson.Length))
                );
                return (false, result.Failure?.Message ?? "Unknown error");
            }
 
            return (true, null);
        }
        catch (Exception ex)
        {
            // Перехватываем исключения, которые Identity может выкинуть
            _logger.LogError(ex, "Exception during Passkey verification");
            return (false, "Internal verification error");
        }
    }
}
Исключения вылетают редко, но бывает. Например, если клиент передал совсем кривой JSON - не валидный CBOR внутри. Или если challenge в cookie протух - у них есть expiration, хотя документация не говорит какой именно. На практике обнаружил, что около 5 минут. После этого 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



Нажмите на изображение для увеличения
Название: Passkey в ASP.NET Core identity 3.jpg
Просмотров: 40
Размер:	112.5 Кб
ID:	11352

Клиентская часть начинается с вызова серверного endpoint для получения опций регистрации. Тут первый момент, который многие упускают - нужен authenticated контекст. Пользователь должен быть залогинен через обычный пароль, прежде чем регистрировать Passkey. Логика проста: мы не можем создать Passkey для несуществующего или неподтвержденного пользователя. В Blazor шаблоне это реализовано через форму с CSRF токеном:

HTML5
1
2
3
4
5
6
<form @formname="add-passkey" @onsubmit="AddPasskey" method="post">
    <AntiforgeryToken />
    <button type="submit" class="btn btn-primary">
        Добавить новый Passkey
    </button>
</form>
Когда пользователь кликает кнопку, JavaScript перехватывает submit через обработчик событий. Тут важный нюанс - обработчик должен проверять, что событие сабмита вызвано именно этой кнопкой, а не какой-то другой формой на странице. В шаблоне это решается через кастомный элемент passkey-submit:

JavaScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
customElements.define('passkey-submit', class extends HTMLElement {
    static formAssociated = true;
    
    connectedCallback() {
        // Привязываем элемент к форме
        this.internals = this.attachInternals();
        
        // Получаем параметры из атрибутов элемента
        this.operation = this.getAttribute('operation');
        this.credentialName = this.getAttribute('name');
        
        // Вешаем обработчик на submit формы
        this.internals.form.addEventListener('submit', (event) => {
            // Проверяем что именно наша кнопка вызвала submit
            if (event.submitter?.name === '__passkeySubmit') {
                event.preventDefault();
                this.createPasskeyCredential();
            }
        });
    }
    
    async createPasskeyCredential() {
        try {
            // Запрашиваем опции создания с сервера
            const optionsResponse = await fetch('/Account/PasskeyCreationOptions', {
                method: 'POST',
                credentials: 'include' // Важно для передачи cookies
            });
            
            if (!optionsResponse.ok) {
                throw new Error(`Сервер вернул статус ${optionsResponse.status}`);
            }
            
            const optionsJson = await optionsResponse.json();
            
            // Парсим опции в формат WebAuthn
            const options = PublicKeyCredential.parseCreationOptionsFromJSON(optionsJson);
            
            // Вызываем браузерный API для создания credential
            const credential = await navigator.credentials.create({
                publicKey: options
            });
            
            // Отправляем созданный credential на сервер
            this.submitCredential(credential);
            
        } catch (error) {
            this.handleError(error);
        }
    }
});
Метод parseCreationOptionsFromJSON появился в спецификации относительно недавно и избавляет от ручного декодирования base64url строк. Раньше приходилось писать функции типа base64urlToBuffer, конвертировать challenge и user.id из строк в ArrayBuffer. Сейчас браузер делает это сам. Но вот засада - Safari начал поддерживать этот метод только с версии 16, а в корпоративной среде я встречал компы на 15.6. Пришлось делать фоллбэк:

JavaScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
function parseCreationOptions(json) {
    // Пробуем использовать нативный метод
    if (typeof PublicKeyCredential.parseCreationOptionsFromJSON === 'function') {
        return PublicKeyCredential.parseCreationOptionsFromJSON(json);
    }
    
    // Фоллбэк для старых браузеров
    return {
        rp: json.rp,
        user: {
            id: base64urlDecode(json.user.id),
            name: json.user.name,
            displayName: json.user.displayName
        },
        challenge: base64urlDecode(json.challenge),
        pubKeyCredParams: json.pubKeyCredParams,
        timeout: json.timeout,
        excludeCredentials: json.excludeCredentials?.map(cred => ({
            type: cred.type,
            id: base64urlDecode(cred.id)
        })) || [],
        authenticatorSelection: json.authenticatorSelection,
        attestation: json.attestation
    };
}
 
function base64urlDecode(str) {
    // Заменяем URL-safe символы на стандартные base64
    str = str.replace(/-/g, '+').replace(/_/g, '/');
    // Добавляем padding если нужно
    while (str.length % 4) str += '=';
    // Декодируем и конвертируем в Uint8Array
    const binary = atob(str);
    const bytes = new Uint8Array(binary.length);
    for (let i = 0; i < binary.length; i++) {
        bytes[i] = binary.charCodeAt(i);
    }
    return bytes.buffer;
}
Вызов navigator.credentials.create() - момент истины. Тут браузер показывает нативный диалог, и дальнейшие действия зависят от пользователя. Он может выбрать создание ключа в аппаратном токене, в биометрическом сканере Windows Hello, в профиле Chrome, или вообще отменить операцию. Timeout по умолчанию 60 секунд, но можно переопределить через параметр timeout в опциях. Я обычно ставлю 120 - бывает пользователь отвлекается или долго возится с USB-ключом.

Важный момент - метод асинхронный и может зависнуть надолго. Нужен механизм отмены через AbortController:

JavaScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
async createPasskeyCredential() {
    // Отменяем предыдущую попытку если была
    this.abortController?.abort();
    this.abortController = new AbortController();
    
    try {
        const optionsJson = await this.fetchOptions();
        const options = this.parseOptions(optionsJson);
        
        // Передаем signal для возможности отмены
        const credential = await navigator.credentials.create({
            publicKey: options,
            signal: this.abortController.signal
        });
        
        await this.submitCredential(credential);
        
    } catch (error) {
        if (error.name === 'AbortError') {
            // Пользователь отменил операцию - это нормально
            console.log('Регистрация Passkey отменена пользователем');
            return;
        }
        throw error;
    }
}
 
disconnectedCallback() {
    // Когда элемент удаляется из DOM - отменяем операцию
    this.abortController?.abort();
}
После того как браузер создал credential, нужно отправить его на сервер. Но тут подвох - объект PublicKeyCredential содержит ArrayBuffer'ы, которые нельзя напрямую сериализовать в JSON. Опять же, есть современный метод credential.toJSON(), который делает всю грязную работу, но с фоллбэком на старые браузеры:

JavaScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
submitCredential(credential) {
    // Конвертируем credential в JSON-совместимый формат
    const credentialJson = credential.toJSON ? 
        JSON.stringify(credential.toJSON()) : 
        this.manuallySerializeCredential(credential);
    
    // Создаем FormData для отправки
    const formData = new FormData();
    formData.append('Input.CredentialJson', credentialJson);
    
    // Добавляем antiforgery token если есть
    const token = this.internals.form.querySelector('input[name="__RequestVerificationToken"]');
    if (token) {
        formData.append('__RequestVerificationToken', token.value);
    }
    
    // Отправляем форму программно
    this.internals.setFormValue(formData);
    this.internals.form.submit();
}
 
manuallySerializeCredential(credential) {
    // Ручная сериализация для старых браузеров
    return JSON.stringify({
        id: credential.id,
        rawId: arrayBufferToBase64url(credential.rawId),
        type: credential.type,
        response: {
            attestationObject: arrayBufferToBase64url(
                credential.response.attestationObject
            ),
            clientDataJSON: arrayBufferToBase64url(
                credential.response.clientDataJSON
            )
        }
    });
}
На серверной стороне парсинг и валидация происходят в PerformPasskeyAttestationAsync. Метод проверяет кучу вещей: что challenge совпадает с тем, что мы генерировали, что origin правильный, что формат attestationObject валидный CBOR, что подпись корректна. Любая ошибка приводит к отказу.

Обработка ошибок критична. Пользователи не разбираются в терминологии WebAuthn, поэтому сообщения типа "InvalidStateError" нужно переводить в понятные фразы:

JavaScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
handleError(error) {
    let message;
    
    switch (error.name) {
        case 'NotAllowedError':
            message = 'Регистрация отменена или истекло время ожидания. Попробуйте еще раз.';
            break;
        case 'InvalidStateError':
            message = 'Этот Passkey уже зарегистрирован. Используйте другое устройство.';
            break;
        case 'NotSupportedError':
            message = 'Ваш браузер не поддерживает Passkey. Обновите браузер или используйте Chrome/Edge/Safari.';
            break;
        case 'SecurityError':
            message = 'Ошибка безопасности. Убедитесь что сайт открыт по HTTPS.';
            break;
        default:
            message = `Не удалось создать Passkey: ${error.message}`;
            console.error('Passkey creation error:', error);
    }
    
    // Показываем пользователю понятное сообщение
    alert(message);
}
После успешной регистрации сервер сохраняет Passkey в базу и редиректит на страницу переименования. Это UX-паттерн, который Microsoft внедрил в шаблон - сразу после создания просим дать ключу понятное имя. Без этого все ключи называются "Unnamed passkey", и потом черт ногу сломит разобраться какой откуда.

Реальная засада, с которой столкнулся - резидентные ключи заполняют память аутентификатора. 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-токены:

C#
1
2
3
4
5
6
7
8
9
10
11
12
var args = new PasskeyCreationArgs(passkeyUser)
{
    AuthenticatorSelection = new AuthenticatorSelectionCriteria
    {
        // Требуем только external устройства
        AuthenticatorAttachment = AuthenticatorAttachment.CrossPlatform,
        // Обязательна верификация пользователя (биометрия/PIN)
        UserVerification = UserVerificationRequirement.Required,
        // Требуем создание resident key
        ResidentKey = ResidentKeyRequirement.Required
    }
};
Видел проект где этот момент стоил нервов. Разработчики тестировали на MacBook с Touch ID, всё летало. Деплоят на прод, пользователи с Windows пытаются регистрироваться через USB-ключи - облом. Оказалось, в коде жестко прописан AuthenticatorAttachment.Platform, который разрешает только встроенные аутентификаторы. Половина юзербазы не могла зарегистрировать Passkey, пока не откатили настройку.

UserVerification - это требование к проверке личности пользователя во время операции. Три варианта: Required - обязательна биометрия или PIN, Preferred - желательна но не обязательна, Discouraged - не нужна. Последний вариант звучит странно, но имеет смысл для низкорисковых сценариев - например, вход в форум или читалку новостей. Зачем заставлять прикладывать палец ради просмотра мемов?

Практически я всегда ставлю Required для production систем. Безопасность важнее удобства, особенно когда речь о финансах или персональных данных. Но это создает проблему - старые USB-ключи без биометрии и кнопки требуют только physical presence (нажал кнопку), но не могут сделать user verification. Такие ключи просто откажутся создавать credential с Required. Приходится либо разрешать Preferred, либо заставлять пользователей апгрейдить железо.

Параметр excludeCredentials заслуживает отдельного внимания. Это массив уже зарегистрированных credential ID для текущего пользователя. Передаешь его в опциях создания, и браузер откажется регистрировать тот же самый ключ повторно. Без этого пользователь может случайно зарегистрировать один и тот же аутентификатор несколько раз, захламив список.

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// Получаем все существующие Passkey пользователя
var existingPasskeys = await _userManager.GetPasskeysAsync(user);
 
var args = new PasskeyCreationArgs(passkeyUser)
{
    ExcludeCredentials = existingPasskeys
        .Select(pk => new PublicKeyCredentialDescriptor
        {
            Type = PublicKeyCredentialType.PublicKey,
            Id = pk.CredentialId,
            Transports = pk.Transports // USB, NFC, BLE, internal
        })
        .ToList()
};
Транспорты - это подсказка браузеру о том, как аутентификатор подключается. Если знаем что ключ работает через USB, браузер не будет пытаться искать его через Bluetooth. Ускоряет discovery процесс. Проблема в том, что при регистрации не всегда понятно какие транспорты поддерживает устройство. AttestationObject содержит эту информацию, но парсить CBOR самостоятельно - удовольствие сомнительное. В текущей реализации Identity эти данные просто игнорируются.

CBOR (Concise Binary Object Representation) - это бинарный формат сериализации, что-то среднее между JSON и Protocol Buffers. WebAuthn использует его для передачи attestationObject, потому что компактнее и быстрее парсится чем JSON. Структура выглядит примерно так:

C#
1
2
3
4
5
6
7
8
9
attestationObject = {
    "fmt": "packed",  // Формат аттестации
    "attStmt": {      // Attestation statement
        "alg": -7,    // ES256
        "sig": <signature bytes>,
        "x5c": [<cert chain>]  // Опционально
    },
    "authData": <authenticator data bytes>
}
В authData закопано всё самое интересное: relying party ID hash, флаги, счетчик подписей, credential ID, открытый ключ в формате COSE. Парсинг этого вручную - боль. К счастью, PerformPasskeyAttestationAsync делает это за нас, но понимание структуры помогает при отладке. Когда видишь ошибку "Invalid authData length", знаешь что искать проблему в размере credential ID или publicKey.

Синхронная vs асинхронная регистрация - еще один нюанс. В примерах выше клиент ждет ответа от сервера синхронно - форма блокируется, пока не придет редирект. Это простое решение, но не самое юзер-френдли. Лучше делать через AJAX с показом прогресса:

JavaScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
async submitCredential(credential) {
  const credentialJson = JSON.stringify(credential.toJSON());
  
  // Показываем индикатор загрузки
  this.showSpinner();
  
  try {
      const response = await fetch('/Account/RegisterPasskey', {
          method: 'POST',
          headers: {
              'Content-Type': 'application/json',
              'RequestVerificationToken': this.getAntiforgeryToken()
          },
          body: JSON.stringify({
              credentialJson: credentialJson
          }),
          credentials: 'include'
      });
      
      if (!response.ok) {
          const error = await response.text();
          throw new Error(error);
      }
      
      const result = await response.json();
      
      // Редиректим на страницу переименования
      window.location.href = `/Account/Manage/RenamePasskey/${result.credentialId}`;
      
  } catch (error) {
      this.hideSpinner();
      this.showError(error.message);
  }
}
CORS и cookies - классическая проблема при работе с разными доменами. Если фронтенд на 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, а не все подряд.

JavaScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
async function requestPasskeyLogin(username = null) {
    try {
        // Формируем URL с username если есть
        const url = username ? 
            [INLINE]/Account/PasskeyRequestOptions?username=${encodeURIComponent(username)}[/INLINE] :
            '/Account/PasskeyRequestOptions';
        
        // Получаем опции с сервера
        const response = await fetch(url, {
            method: 'POST',
            credentials: 'include'
        });
        
        if (!response.ok) {
            throw new Error(`Сервер вернул ошибку: ${response.status}`);
        }
        
        const optionsJson = await response.json();
        
        // Парсим опции для WebAuthn API
        const options = PublicKeyCredential.parseRequestOptionsFromJSON 
            ? PublicKeyCredential.parseRequestOptionsFromJSON(optionsJson)
            : manuallyParseRequestOptions(optionsJson);
        
        // Запрашиваем credential у браузера
        const credential = await navigator.credentials.get({
            publicKey: options,
            mediation: 'conditional' // Включаем autofill в поле логина
        });
        
        // Отправляем полученный credential на сервер
        await submitLoginCredential(credential);
        
    } catch (error) {
        handleLoginError(error);
    }
}
Параметр mediation: 'conditional' - это новая фича, называемая conditional UI. Если браузер поддерживает её, сохраненные Passkey появляются прямо в автозаполнении поля username. Пользователь видит список своих аккаунтов с иконками, выбирает нужный, подтверждает биометрией - готово, вошел. Без всякого ручного выбора credential. Работает в Chrome 108+, Safari 16+, Firefox 119+. Старые браузеры просто игнорируют этот параметр.

Видел кейс где conditional UI создал путаницу. Дизайнеры сделали кастомное поле ввода email со стилизацией, которая перекрывала нативный автозаполнение браузера. Пользователи не видели Passkey в списке и думали что функция не работает. Пришлось переделывать на стандартный input с минимальными стилями.
На сервере генерация опций для входа проще чем для регистрации. Не нужна информация о пользователе в явном виде, только challenge и опциональный список allowedCredentials:

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
[HttpPost]
public async Task<IActionResult> PasskeyRequestOptions([FromQuery] string? username)
{
    ApplicationUser? user = null;
    
    // Если username предоставлен, находим пользователя
    if (!string.IsNullOrEmpty(username))
    {
        user = await _userManager.FindByNameAsync(username);
    }
    
    var args = new PasskeyRequestArgs<ApplicationUser>
    {
        User = user
    };
    
    // Генерируем опции и сохраняем challenge в cookie
    var options = await _signInManager.ConfigurePasskeyRequestOptionsAsync(args);
    
    return Content(options.AsJson(), "application/json");
}
Если пользователь передан, метод ConfigurePasskeyRequestOptionsAsync добавит в опции список его credential ID. Браузер покажет только эти ключи. Если пользователь не передан, браузер покажет все Passkey которые работают с этим доменом. Это называется username-less flow, удобно для быстрого входа, но может запутать если у юзера несколько аккаунтов на одном сайте.
JSON ответа минималистичнее чем при регистрации:

JSON
1
2
3
4
5
6
7
8
9
10
11
12
{
    "challenge": "cmFuZG9tY2hhbGxlbmdl",
    "timeout": 60000,
    "rpId": "localhost",
    "allowCredentials": [
        {
            "type": "public-key",
            "id": "Y3JlZGVudGlhbElk..."
        }
    ],
    "userVerification": "preferred"
}
Обрати внимание на allowCredentials - если он пустой, браузер предложит все доступные ключи. Если заполнен, только указанные. В корпоративных системах где у сотрудников может быть по 5-10 ключей от разных сервисов, фильтрация критична. Иначе пользователь видит огромный список и не понимает какой выбрать.

UserVerification тут тоже имеет значение. Preferred означает что браузер попытается сделать user verification если возможно, но не обязательно. Если установить Required, старые USB-ключи без биометрии откажутся работать. Приходится балансировать между безопасностью и совместимостью. Для финансовых приложений я всегда ставлю Required, для остальных - Preferred.

После того как браузер получил подпись от аутентификатора, нужно отправить её на сервер. Структура credential для аутентификации отличается от регистрации - вместо attestationObject приходит authenticatorData и signature отдельно:

JavaScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
async function submitLoginCredential(credential) {
    const credentialJson = JSON.stringify({
        id: credential.id,
        rawId: arrayBufferToBase64url(credential.rawId),
        type: credential.type,
        response: {
            authenticatorData: arrayBufferToBase64url(
                credential.response.authenticatorData
            ),
            clientDataJSON: arrayBufferToBase64url(
                credential.response.clientDataJSON
            ),
            signature: arrayBufferToBase64url(
                credential.response.signature
            ),
            userHandle: credential.response.userHandle ? 
                arrayBufferToBase64url(credential.response.userHandle) : null
        }
    });
    
    // Отправляем через форму или AJAX
    const formData = new FormData();
    formData.append('Input.CredentialJson', credentialJson);
    
    const response = await fetch('/Account/PasskeyLogin', {
        method: 'POST',
        body: formData,
        credentials: 'include'
    });
    
    if (response.ok) {
        // Успешный вход - редирект на главную
        window.location.href = '/';
    } else {
        throw new Error('Ошибка входа через Passkey');
    }
}
UserHandle - это закодированный user ID, который аутентификатор может вернуть при использовании resident keys. Полезно для username-less сценариев - даже если пользователь не ввел email, сервер узнает кто логинится по userHandle. Но не все аутентификаторы его возвращают, поэтому нужна проверка на null.
Серверная валидация - самая важная часть. Тут проверяется подпись, счетчик, флаги присутствия пользователя:

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
[HttpPost]
public async Task<IActionResult> PasskeyLogin([FromForm] PasskeyLoginInput input)
{
    try
    {
        // Достаем сохраненные опции из cookie
        var options = await _signInManager.RetrievePasskeyRequestOptionsAsync();
        if (options == null)
        {
            return BadRequest("Опции аутентификации не найдены или истекли");
        }
        
        // Валидируем credential и выполняем вход
        var result = await _signInManager.PasskeySignInAsync(
            input.CredentialJson,
            options
        );
        
        if (result.Succeeded)
        {
            _logger.LogInformation("Успешный вход через Passkey");
            return LocalRedirect("/");
        }
        
        if (result.IsLockedOut)
        {
            return RedirectToPage("/Account/Lockout");
        }
        
        return BadRequest("Не удалось войти через Passkey");
        
    }
    catch (Exception ex)
    {
        _logger.LogError(ex, "Ошибка при входе через Passkey");
        return StatusCode(500, "Внутренняя ошибка сервера");
    }
}
PasskeySignInAsync под капотом выполняет кучу проверок. Парсит authenticatorData, извлекает rpIdHash и проверяет что он соответствует текущему домену. Это защита от фишинга - даже если пользователь попал на поддельный сайт, хеш не совпадет. Проверяет флаги user present и user verified - убеждается что пользователь реально присутствовал при аутентификации. Валидирует signature используя сохраненный публичный ключ.

Самое интересное - проверка счетчика подписей. Каждый раз когда аутентификатор создает подпись, он инкрементирует внутренний счетчик и включает его в authenticatorData. Сервер должен проверить что новый счетчик больше предыдущего сохраненного значения. Если счетчик меньше или равен - это признак клонированного ключа или replay атаки:

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// Псевдокод внутренней логики проверки счетчика
var storedCounter = await GetStoredSignCountAsync(credentialId);
var receivedCounter = ExtractCounterFromAuthData(authenticatorData);
 
if (receivedCounter > 0 && receivedCounter <= storedCounter)
{
    // Возможное клонирование ключа!
    _logger.LogWarning(
        "Подозрительный счетчик подписей. Credential: {Id}, Stored: {Stored}, Received: {Received}",
        credentialId, storedCounter, receivedCounter
    );
    
    // Можно заблокировать credential или послать алерт админам
    await DisableCredentialAsync(credentialId);
    return SignInResult.Failed;
}
 
// Обновляем сохраненный счетчик
await UpdateSignCountAsync(credentialId, receivedCounter);
На практике большинство аутентификаторов возвращают счетчик 0 при каждом использовании. Это легальное поведение согласно спецификации - если устройство не может гарантировать монотонное увеличение, оно должно возвращать 0. Проверка счетчика в таких случаях бесполезна, но отключать её нельзя - ради тех устройств которые реально его поддерживают. Поддержка нескольких Passkey одного пользователя работает автоматически. Браузер показывает список всех зарегистрированных ключей, пользователь выбирает нужный. Сервер определяет какой ключ использован по credential ID в ответе и достает соответствующий публичный ключ из базы для проверки подписи. Никаких специальных действий от разработчика не требуется.

Но есть нюанс с производительностью. Если у пользователя 10 ключей и мы передаем все их ID в allowCredentials, объем JSON вырастает. Каждый credential ID - это 16-32 байта в base64url, плюс обертка. При большом количестве пользователей и ключей это начинает давить на трафик. Оптимизация - кешировать список credential ID на клиенте в localStorage и обновлять только при изменениях. Правда это создает риск рассинхронизации, если пользователь удалил ключ с другого устройства.

Обработка ошибок при логине критична для UX. Пользователи не понимают технических терминов, нужны человеческие сообщения:

JavaScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
function handleLoginError(error) {
    let message;
    
    switch (error.name) {
        case 'NotAllowedError':
            message = 'Вход отменен или время истекло';
            break;
        case 'InvalidStateError':
            message = 'Passkey не найден. Возможно вы используете другое устройство?';
            break;
        case 'NotSupportedError':
            message = 'Ваш браузер не поддерживает Passkey';
            break;
        case 'SecurityError':
            message = 'Ошибка безопасности. Проверьте что используете HTTPS';
            break;
        case 'UnknownError':
            // Обычно означает что credential был удален
            message = 'Passkey больше не действителен. Войдите через пароль и зарегистрируйте новый';
            break;
        default:
            message = 'Не удалось войти через Passkey. Попробуйте другой способ входа';
            _logger.error('Passkey login error:', error);
    }
    
    showErrorMessage(message);
}
UnknownError особенно коварен - браузер выбрасывает его когда credential существует в менеджере паролей, но сервер его не признает. Обычно это означает что админ удалил ключ из базы, но пользователь не знает об этом. Видел случаи когда юзеры по 20 минут пытались войти, не понимая в чем проблема. Добавил явное сообщение - количество обращений в поддержку упало. Фоллбэк на пароль обязателен. Passkey крутая технология, но не панацея. Устройство может сломаться, потеряться, разрядиться. Всегда должен быть запасной вариант входа. В шаблоне Microsoft это реализовано через обычную форму логина рядом с кнопкой Passkey. Просто и работает.

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 недоступен:

JavaScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
async function isPasskeyAvailable() {
  // Проверяем наличие базового API
  if (!window.PublicKeyCredential) {
      return false;
  }
  
  // Проверяем поддержку условного UI (опционально)
  try {
      const available = await PublicKeyCredential
          .isConditionalMediationAvailable();
      return available;
  } catch {
      // Старые браузеры могут не иметь этого метода
      return true;
  }
}
 
// При загрузке страницы проверяем и показываем/скрываем UI
document.addEventListener('DOMContentLoaded', async () => {
  const passkeyButton = document.getElementById('passkey-login');
  if (passkeyButton && !await isPasskeyAvailable()) {
      passkeyButton.style.display = 'none';
      // Показываем сообщение о необходимости обновить браузер
      showBrowserUpdateNotice();
  }
});
Мобильные браузеры добавляют свои приколы. На Android Chrome всё работает с версии 70, но TouchID/FaceID доступны только если приложение установлено как PWA. В обычном браузере придется использовать PIN-код экрана блокировки. На iOS Safari требует чтобы сайт был добавлен в закладки для полноценной работы Face ID. Это не баг, это фича Apple для приватности - не хотят чтобы любой сайт мог запрашивать биометрию без явного действия пользователя.

Корпоративные прокси умеют ломать 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 может прыгать:

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
private async Task LogPasskeyAuthentication(
  string userId,
  byte[] credentialId, 
  bool success,
  string? failureReason = null)
{
  var logEntry = new PasskeyAuthLog
  {
      Timestamp = DateTime.UtcNow,
      UserId = userId,
      CredentialId = Convert.ToBase64String(credentialId),
      IpAddress = HttpContext.Connection.RemoteIpAddress?.ToString(),
      UserAgent = HttpContext.Request.Headers["User-Agent"].ToString(),
      Success = success,
      FailureReason = failureReason
  };
  
  await _auditLogRepository.AddAsync(logEntry);
  
  // Дополнительно отправляем в SIEM если доступен
  if (_siemService != null)
  {
      await _siemService.SendEventAsync("passkey_auth", logEntry);
  }
}
Мониторинг метрик дает понимание здоровья системы. Отслеживать стоит: процент успешных входов через Passkey, среднее время верификации подписи, количество timeout'ов, частоту разных типов ошибок. Если внезапно упал success rate - возможно баг в коде после деплоя. Если выросло время верификации - может быть база данных тормозит. Если много NotAllowedError - юзеры отменяют операцию, возможно UI непонятный.

Graceful degradation при проблемах на стороне браузера важна. Если WebAuthn API выбросил исключение, нельзя просто показать 500 ошибку. Нужно логировать детали для отладки, но пользователю предложить альтернативу - войти через пароль, или попробовать другой браузер. Видел системы которые при первой ошибке Passkey автоматически редиректили на форму с паролем и SMS кодом, без объяснений. Пользователи думали что Passkey у них сломался навсегда и больше не пробовали.

Архитектура безопасности и внутренние механизмы



Криптографическая архитектура Passkey строится на математике, которую не взломать перебором даже если вселенная просуществует еще миллиард лет. Основа - асимметричное шифрование на эллиптических кривых, конкретно алгоритм ECDSA (Elliptic Curve Digital Signature Algorithm). Выбор не случаен - эллиптические кривые дают такую же стойкость как RSA, но с ключами в четыре раза короче. 256-битный ключ на кривой P-256 эквивалентен 3072-битному RSA ключу по сложности взлома.

Математика кривых элегантна. Определяется уравнение вида https://www.cyberforum.ru/cgi-bin/latex.cgi?y^2 = x^3 + ax + b \pmod{p}, где https://www.cyberforum.ru/cgi-bin/latex.cgi?p - большое простое число. На этой кривой выбирается базовая точка https://www.cyberforum.ru/cgi-bin/latex.cgi?G, и закрытый ключ - это просто случайное число https://www.cyberforum.ru/cgi-bin/latex.cgi?d из диапазона https://www.cyberforum.ru/cgi-bin/latex.cgi?[1, n-1], где https://www.cyberforum.ru/cgi-bin/latex.cgi?n - порядок точки. Открытый ключ вычисляется как https://www.cyberforum.ru/cgi-bin/latex.cgi?Q = d \times G - скалярное умножение точки на число. Обратная операция - найти https://www.cyberforum.ru/cgi-bin/latex.cgi?d зная https://www.cyberforum.ru/cgi-bin/latex.cgi?Q и https://www.cyberforum.ru/cgi-bin/latex.cgi?G - это дискретное логарифмирование на эллиптической кривой, задача которую никто не умеет решать эффективно.

Когда создается подпись, используется формула:

https://www.cyberforum.ru/cgi-bin/latex.cgi?s = k^{-1}(h + rd) \pmod{n}

Где https://www.cyberforum.ru/cgi-bin/latex.cgi?k - случайное число (nonce), https://www.cyberforum.ru/cgi-bin/latex.cgi?h - хеш подписываемого сообщения, https://www.cyberforum.ru/cgi-bin/latex.cgi?r - x-координата точки https://www.cyberforum.ru/cgi-bin/latex.cgi?k \times G, https://www.cyberforum.ru/cgi-bin/latex.cgi?d - закрытый ключ. Подпись состоит из пары https://www.cyberforum.ru/cgi-bin/latex.cgi?(r, s). Верификация проверяет что точка https://www.cyberforum.ru/cgi-bin/latex.cgi?s^{-1} \times h \times G + s^{-1} \times r \times Q имеет x-координату равную https://www.cyberforum.ru/cgi-bin/latex.cgi?r. Если равна - подпись валидна, если нет - фальшивка.

Важнейшая деталь - случайность https://www.cyberforum.ru/cgi-bin/latex.cgi?k. Если использовать одинаковый nonce для двух разных подписей, закрытый ключ вычисляется тривиально через систему линейных уравнений. Именно так взламывали PlayStation 3 - Sony косячили с генератором случайных чисел. В Passkey за генерацию nonce отвечает аутентификатор, и качество его RNG критично. Хорошие устройства используют аппаратный TRNG (True Random Number Generator), дешевые - PRNG с энтропией от таймингов и прочих источников. Хеширование применяется на каждом шаге. Challenge хешируется в clientDataHash через SHA-256. AuthenticatorData тоже хешируется перед подписанием. Relying party ID превращается в rpIdHash. Зачем столько хеширования? Защита от length extension атак и обеспечение криптографической связности всех компонентов. Нельзя изменить один байт в данных без того чтобы хеш не развалился полностью.

Привязка к домену - гениальное решение проблемы фишинга. Когда браузер вызывает 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). Структура проекта получилась следующая:

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
SecureAuthPortal/
├── Components/
│   ├── Account/
│   │   ├── Pages/
│   │   │   ├── Login.razor           // Форма входа с Passkey
│   │   │   ├── Register.razor        // Регистрация аккаунта
│   │   │   └── PasskeyManager.razor  // Управление ключами
│   │   └── Shared/
│   │       ├── PasskeyButton.razor   // Переиспользуемая кнопка
│   │       └── PasskeyStatus.razor   // Индикатор статуса
│   └── Layout/
│       └── MainLayout.razor
├── Services/
│   ├── PasskeyService.cs             // Обертка над Identity
│   ├── AuditLogger.cs                // Логирование аутентификации
│   └── PasskeyAnalytics.cs           // Метрики использования
├── Data/
│   ├── ApplicationDbContext.cs
│   ├── Migrations/
│   └── Entities/
│       └── PasskeyAuditLog.cs
└── wwwroot/
    └── js/
        └── passkey-client.js         // Клиентская логика WebAuthn
Решил добавить отдельный слой сервисов поверх стандартного UserManager и SignInManager. Зачем? Потому что напрямую работать с Identity в компонентах - это боль при тестировании и рефакторинге. Если завтра захочу добавить дополнительную валидацию или кастомную логику, придется лезть во все компоненты. А так меняю только один сервис.

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
public class PasskeyService
{
    private readonly UserManager<ApplicationUser> _userManager;
    private readonly SignInManager<ApplicationUser> _signInManager;
    private readonly AuditLogger _auditLogger;
    
    public async Task<PasskeyRegistrationResult> RegisterPasskeyAsync(
        ApplicationUser user, 
        string credentialJson)
    {
        // Получаем сохраненные опции из cookie
        var options = await _signInManager.RetrievePasskeyCreationOptionsAsync();
        if (options == null)
        {
            return PasskeyRegistrationResult.Failed("Опции регистрации истекли");
        }
        
        // Валидируем credential
        var attestation = await _signInManager.PerformPasskeyAttestationAsync(
            credentialJson, 
            options
        );
        
        if (!attestation.Succeeded)
        {
            await _auditLogger.LogFailedRegistrationAsync(
                user.Id, 
                attestation.Failure?.Message
            );
            return PasskeyRegistrationResult.Failed(
                attestation.Failure?.Message ?? "Ошибка валидации"
            );
        }
        
        // Проверяем лимит ключей на пользователя
        var existingKeys = await _userManager.GetPasskeysAsync(user);
        if (existingKeys.Count >= 10)
        {
            return PasskeyRegistrationResult.Failed(
                "Достигнут лимит ключей на аккаунт"
            );
        }
        
        // Сохраняем в базу
        var result = await _userManager.SetPasskeyAsync(user, attestation.Passkey);
        if (!result.Succeeded)
        {
            return PasskeyRegistrationResult.Failed("Не удалось сохранить ключ");
        }
        
        // Логируем успех
        await _auditLogger.LogSuccessfulRegistrationAsync(
            user.Id,
            attestation.Passkey.CredentialId
        );
        
        return PasskeyRegistrationResult.Success(attestation.Passkey);
    }
    
    public async Task<PasskeyAuthenticationResult> AuthenticateAsync(
        string credentialJson)
    {
        var options = await _signInManager.RetrievePasskeyRequestOptionsAsync();
        if (options == null)
        {
            return PasskeyAuthenticationResult.Failed("Опции аутентификации истекли");
        }
        
        var result = await _signInManager.PasskeySignInAsync(
            credentialJson,
            options
        );
        
        if (result.Succeeded)
        {
            // Получаем информацию о пользователе для логирования
            var principal = result.Principal;
            var userId = principal?.FindFirst(ClaimTypes.NameIdentifier)?.Value;
            
            if (userId != null)
            {
                await _auditLogger.LogSuccessfulLoginAsync(userId);
            }
            
            return PasskeyAuthenticationResult.Success(result);
        }
        
        return PasskeyAuthenticationResult.Failed("Аутентификация не удалась");
    }
}
Добавил лимит на количество ключей - десять штук на пользователя. Больше и не нужно обычно, а защита от абуза имеется. Видел случай когда пользователь регистрировал по ключу каждый день "для теста" и через полгода у него была сотня неиспользуемых credentials в базе. Захламление чистое.
Audit logging реализовал через отдельную таблицу. Каждая операция с Passkey пишется туда с полными деталями:

C#
1
2
3
4
5
6
7
8
9
10
11
12
public class PasskeyAuditLog
{
    public int Id { get; set; }
    public DateTime Timestamp { get; set; }
    public string UserId { get; set; }
    public string Operation { get; set; } // Register, Login, Delete
    public bool Success { get; set; }
    public string? CredentialId { get; set; }
    public string? IpAddress { get; set; }
    public string? UserAgent { get; set; }
    public string? ErrorMessage { get; set; }
}
Для метрик создал простой сервис который агрегирует данные из audit log:

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
public class PasskeyAnalytics
{
    private readonly ApplicationDbContext _context;
    
    public async Task<PasskeyStats> GetStatsAsync()
    {
        var logs = await _context.PasskeyAuditLogs
            .Where(l => l.Timestamp >= DateTime.UtcNow.AddDays(-30))
            .ToListAsync();
        
        return new PasskeyStats
        {
            TotalRegistrations = logs.Count(l => l.Operation == "Register" && l.Success),
            TotalLogins = logs.Count(l => l.Operation == "Login"),
            SuccessRate = CalculateSuccessRate(logs.Where(l => l.Operation == "Login")),
            MostActiveUsers = await GetMostActiveUsersAsync(),
            AverageKeysPerUser = await CalculateAverageKeysAsync()
        };
    }
    
    private double CalculateSuccessRate(IEnumerable<PasskeyAuditLog> loginLogs)
    {
        if (!loginLogs.Any()) return 0;
        
        var successful = loginLogs.Count(l => l.Success);
        return (double)successful / loginLogs.Count() * 100;
    }
}
На клиенте JavaScript инкапсулировал в отдельный модуль вместо того чтобы размазывать по компонентам. Получилась чистая абстракция над WebAuthn API:

JavaScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
// passkey-client.js
export class PasskeyClient {
    constructor(baseUrl) {
        this.baseUrl = baseUrl;
    }
    
    async register() {
        // Получаем опции с сервера
        const options = await this.fetchCreationOptions();
        
        // Создаем credential
        const credential = await navigator.credentials.create({
            publicKey: this.parseOptions(options)
        });
        
        // Отправляем на сервер
        return await this.submitCredential(credential, '/Account/RegisterPasskey');
    }
    
    async login(username = null) {
        const options = await this.fetchRequestOptions(username);
        
        const credential = await navigator.credentials.get({
            publicKey: this.parseOptions(options),
            mediation: 'conditional'
        });
        
        return await this.submitCredential(credential, '/Account/PasskeyLogin');
    }
    
    async fetchCreationOptions() {
        const response = await fetch(`${this.baseUrl}/PasskeyCreationOptions`, {
            method: 'POST',
            credentials: 'include'
        });
        
        if (!response.ok) {
            throw new Error(`HTTP ${response.status}`);
        }
        
        return await response.json();
    }
    
    // Остальные методы...
}
Blazor компоненты получились компактными благодаря вынесению логики в сервисы. Форма логина выглядит так:

HTML5
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
@page "/Account/Login"
@inject PasskeyService PasskeyService
@inject NavigationManager Navigation
 
<h1>Вход в систему</h1>
 
<EditForm Model="Input" OnValidSubmit="HandleLogin">
    <DataAnnotationsValidator />
    
    @if (!string.IsNullOrEmpty(errorMessage))
    {
        <div class="alert alert-danger">@errorMessage</div>
    }
    
    <div class="form-group">
        <label>Email</label>
        <InputText @bind-Value="Input.Email" class="form-control" />
        <ValidationMessage For="() => Input.Email" />
    </div>
    
    <div class="form-group">
        <label>Пароль</label>
        <InputText type="password" @bind-Value="Input.Password" class="form-control" />
        <ValidationMessage For="() => Input.Password" />
    </div>
    
    <button type="submit" class="btn btn-primary">Войти с паролем</button>
    <button type="button" @onclick="LoginWithPasskey" class="btn btn-success">
        Войти с Passkey
    </button>
</EditForm>
 
@code {
    private LoginInputModel Input { get; set; } = new();
    private string? errorMessage;
    
    private async Task LoginWithPasskey()
    {
        try
        {
            // Вызываем JS interop для работы с WebAuthn
            var credentialJson = await JS.InvokeAsync<string>(
                "passkeyClient.login",
                Input.Email
            );
            
            var result = await PasskeyService.AuthenticateAsync(credentialJson);
            
            if (result.Success)
            {
                Navigation.NavigateTo("/");
            }
            else
            {
                errorMessage = result.ErrorMessage;
            }
        }
        catch (Exception ex)
        {
            errorMessage = "Ошибка входа через Passkey. Попробуйте с паролем.";
            // Логируем детали
            Logger.LogError(ex, "Passkey login failed");
        }
    }
}
База данных использует стандартный SQL Server LocalDB для разработки, но конфигурация позволяет легко переключиться на PostgreSQL или MySQL через connection string. Миграции создал сразу для всех необходимых таблиц включая audit log. Все это можно запустить командой dotnet run, открыть в браузере, зарегистрировать аккаунт и сразу добавить Passkey. Работает из коробки, без дополнительных настроек. Единственное требование - HTTPS или localhost, иначе WebAuthn откажется функционировать.

Управление ключами реализовал через отдельный компонент PasskeyManager, который показывает список зарегистрированных credentials с возможностью переименования и удаления. Тут важный момент - нельзя просто удалять ключи без подтверждения, иначе пользователь случайно кликнет и останется без доступа.

HTML5
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
@page "/Account/ManagePasskeys"
@inject PasskeyService PasskeyService
@inject UserManager<ApplicationUser> UserManager
 
<h2>Управление Passkey</h2>
 
@if (passkeys == null)
{
    <p>Загрузка...</p>
}
else if (!passkeys.Any())
{
    <div class="alert alert-info">
        У вас пока нет зарегистрированных Passkey. 
        <a href="/Account/AddPasskey">Добавить первый ключ</a>
    </div>
}
else
{
    <table class="table">
        <thead>
            <tr>
                <th>Название</th>
                <th>Дата создания</th>
                <th>Последнее использование</th>
                <th>Действия</th>
            </tr>
        </thead>
        <tbody>
            @foreach (var key in passkeys)
            {
                <tr>
                    <td>
                        @if (editingKeyId == key.CredentialId)
                        {
                            <input @bind="newName" class="form-control" />
                        }
                        else
                        {
                            @key.Name
                        }
                    </td>
                    <td>@key.CreatedAt.ToLocalTime().ToString("dd.MM.yyyy HH:mm")</td>
                    <td>@(key.LastUsed?.ToLocalTime().ToString("dd.MM.yyyy HH:mm") ?? "Не использовался")</td>
                    <td>
                        @if (editingKeyId == key.CredentialId)
                        {
                            <button @onclick="() => SaveName(key)" class="btn btn-sm btn-success">
                                Сохранить
                            </button>
                            <button @onclick="CancelEdit" class="btn btn-sm btn-secondary">
                                Отмена
                            </button>
                        }
                        else
                        {
                            <button @onclick="() => StartEdit(key)" class="btn btn-sm btn-primary">
                                Переименовать
                            </button>
                            <button @onclick="() => ShowDeleteConfirmation(key)" 
                                    class="btn btn-sm btn-danger">
                                Удалить
                            </button>
                        }
                    </td>
                </tr>
            }
        </tbody>
    </table>
}
 
@if (showDeleteDialog)
{
    <div class="modal show d-block" tabindex="-1">
        <div class="modal-dialog">
            <div class="modal-content">
                <div class="modal-header">
                    <h5 class="modal-title">Подтверждение удаления</h5>
                </div>
                <div class="modal-body">
                    <p>Вы уверены что хотите удалить ключ "@keyToDelete?.Name"?</p>
                    <p class="text-warning">Это действие нельзя отменить.</p>
                </div>
                <div class="modal-footer">
                    <button @onclick="CancelDelete" class="btn btn-secondary">
                        Отмена
                    </button>
                    <button @onclick="ConfirmDelete" class="btn btn-danger">
                        Удалить
                    </button>
                </div>
            </div>
        </div>
    </div>
    <div class="modal-backdrop show"></div>
}
 
@code {
    private List<PasskeyInfo> passkeys;
    private byte[]? editingKeyId;
    private string newName = "";
    private bool showDeleteDialog;
    private PasskeyInfo? keyToDelete;
 
    protected override async Task OnInitializedAsync()
    {
        var user = await UserManager.GetUserAsync(HttpContext.User);
        passkeys = await PasskeyService.GetUserPasskeysAsync(user);
    }
 
    private async Task ConfirmDelete()
    {
        if (keyToDelete != null)
        {
            var user = await UserManager.GetUserAsync(HttpContext.User);
            await PasskeyService.RemovePasskeyAsync(user, keyToDelete.CredentialId);
            passkeys = await PasskeyService.GetUserPasskeysAsync(user);
        }
        
        showDeleteDialog = false;
        keyToDelete = null;
    }
}
Обработка ошибок везде делается через try-catch с детальным логированием, но пользователю показываются упрощенные сообщения. Никакого "Exception in PasskeyService.RegisterAsync line 42" - только "Не удалось зарегистрировать ключ. Попробуйте еще раз или обратитесь в поддержку".

Тестирование заслуживает отдельного внимания. Unit-тесты для сервисов пишутся легко благодаря инъекции зависимостей. Мокаю UserManager, SignInManager и проверяю что методы вызываются с правильными параметрами. Integration тесты сложнее - нужен реальный браузер для WebAuthn. Использовал Playwright с виртуальным аутентификатором:

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
[Fact]
public async Task CanRegisterPasskey_WithVirtualAuthenticator()
{
    await using var context = await Browser.NewContextAsync(new()
    {
        // Добавляем виртуальный аутентификатор
        HasWebAuthn = true
    });
    
    var page = await context.NewPageAsync();
    await page.GotoAsync("http://localhost:5000/Account/Register");
    
    // Регистрируем аккаунт
    await page.FillAsync("#Email", "test@example.com");
    await page.FillAsync("#Password", "Pass@word123");
    await page.ClickAsync("button[type=submit]");
    
    // Добавляем Passkey
    await page.WaitForURLAsync("**/Account/ManagePasskeys");
    await page.ClickAsync("text=Добавить Passkey");
    
    // Виртуальный аутентификатор автоматически подтверждает
    await page.WaitForSelectorAsync("text=Passkey успешно зарегистрирован");
    
    // Проверяем что ключ появился в списке
    var keysCount = await page.Locator("table tbody tr").CountAsync();
    Assert.Equal(1, keysCount);
}
Deployment в production требует нескольких дополнительных шагов. HTTPS обязателен - без него WebAuthn просто не запустится. Получил Let's Encrypt сертификат через certbot, настроил автоматическое обновление. Connection string для базы храню в Azure Key Vault, не в appsettings - безопасность превыше удобства.

Мониторинг настроил через Application Insights. Трекаю кастомные метрики: количество Passkey регистраций за час, success rate логинов, average latency проверки подписей. Алерты на аномалии - если error rate подскакивает выше 10% или registration rate падает в ноль, прилетает уведомление в Teams канал.

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
public class PasskeyTelemetry
{
    private readonly TelemetryClient _telemetry;
    
    public void TrackPasskeyRegistration(bool success, TimeSpan duration)
    {
        _telemetry.TrackEvent("PasskeyRegistration", 
            properties: new Dictionary<string, string>
            {
                { "Success", success.ToString() },
                { "Duration", duration.TotalMilliseconds.ToString() }
            },
            metrics: new Dictionary<string, double>
            {
                { "RegistrationTime", duration.TotalMilliseconds }
            }
        );
    }
    
    public void TrackPasskeyLogin(bool success, string? errorReason = null)
    {
        var properties = new Dictionary<string, string>
        {
            { "Success", success.ToString() }
        };
        
        if (errorReason != null)
        {
            properties["ErrorReason"] = errorReason;
        }
        
        _telemetry.TrackEvent("PasskeyLogin", properties);
    }
}
Health checks добавил для критичных компонентов. Проверяю что database доступна, что Identity сервисы отвечают, что генерация challenge работает. Endpoint `/health` возвращает JSON со статусом всех систем - полезно для load balancer'а чтобы понимать куда роутить трафик.

Финальный проект получился компактным - около 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
Добрый день. В своем проекте я хочу использовать двухуровневую систему - Web(MVC) и DAL(library)...

Очистка cookies в ASP.NET Core Identity
Добрый день! Подскажите, пожалуйста, каким образом можно почистить куки, если администратор поменял...

ASP.NET Core Identity (в проекте WebApi)
админам: Я не увидел разделов для кора или для веб апи, если я их пропустил, плз перенесите и не...

Identity в ASP.NET Core 2.0
Доброе время суток. Пытаюсь реализовать авторизацю с помощью Identity на asp.net core 2.0. Но...

Можно ли использовать ASP.Net Core Identity вместе с Angular?
Собственно вопрос в названии темы

Использование Identity Server и ASP .Net Core 3.00 с Angular
Приложение Angular ASP Net Core создано на основе шаблона VS2019 .NetCore 3.0 с аутентификацией и...

ASP.NET Core Identity - Из коробки или добавление самостоятельно
Всем добрый вечер. Отталкиваясь от учебных пособий в сети, существует мнение, что оптимальнее...

Ошибка при запуске приложения ASP.Net Core Identity
Помогите с изучением ASP.Net Core Identity, первое же приложение и не получается, вылетает вот это

Где находится контроллер регистрации в стандартном шаблоне Visual Studio asp net core mvc + identity
Добрый день, являюсь новичком в asp net core mvc + identity. В стандартном шаблоне asp net core...

Связь таблиц один ко многим в ASP NET Core Identity
Здравствуйте. Хочу создать веб приложение для регистрации в онлайн игре на технологии...

Управление ролями пользователей с помощью Identity в ASP.NET CORE MVC
Я изучаю как работает Identyity в C# MVC и опираюсь на книгу &quot;Фриман А. - ASP.NET Core MVC 2 с...

Надоела реклама? Зарегистрируйтесь и она исчезнет полностью.
Всего комментариев 0
Комментарии
 
Новые блоги и статьи
И решил я переделать этот ноут в машину для распределенных вычислений
Programma_Boinc 09.11.2025
И решил я переделать этот ноут в машину для распределенных вычислений Всем привет. А вот мой компьютер, переделанный из ноутбука. Был у меня ноут асус 2011 года. Со временем корпус превратился. . .
Мысли в слух
kumehtar 07.11.2025
Заметил среди людей, что по-настоящему верная дружба бывает между теми, с кем нечего делить.
Новая зверюга
volvo 07.11.2025
Подарок на Хеллоуин, и теперь у нас кроме Tuxedo Cat есть еще и щенок далматинца: Хочу еще Симбу взять, очень нравится. . .
Инференс ML моделей в Java: TensorFlow, DL4J и DJL
Javaican 05.11.2025
Python захватил мир машинного обучения - это факт. Но когда дело доходит до продакшена, ситуация не так однозначна. Помню проект в крупном банке три года назад: команда data science натренировала. . .
Mapped types (отображённые типы) в TypeScript
Reangularity 03.11.2025
Mapped types работают как конвейер - берут существующую структуру и производят новую по заданным правилам. Меняют модификаторы свойств, трансформируют значения, фильтруют ключи. Один раз описал. . .
Адаптивная случайность в Unity: динамические вероятности для улучшения игрового дизайна
GameUnited 02.11.2025
Мой знакомый геймдизайнер потерял двадцать процентов активной аудитории за неделю. А виновником оказался обычный генератор псевдослучайных чисел. Казалось бы - добавил в карточную игру случайное. . .
Протоколы в Python
py-thonny 31.10.2025
Традиционная утиная типизация работает просто: попробовал вызвать метод, получилось - отлично, не получилось - упал с ошибкой в рантайме. Протоколы добавляют сюда проверку на этапе статического. . .
C++26: Read-copy-update (RCU)
bytestream 30.10.2025
Прошло почти двадцать лет с тех пор, как производители процессоров отказались от гонки мегагерц и перешли на многоядерность. И знаете что? Мы до сих пор спотыкаемся о те же грабли. Каждый раз, когда. . .
Изображения webp на старых x32 ОС Windows XP и Windows 7
Argus19 30.10.2025
Изображения webp на старых x32 ОС Windows XP и Windows 7 Чтобы решить задачу, использовал интернет: поисковики Google и Yandex, а также подсказки Deep Seek. Как оказалось, чтобы создать. . .
Passkey в ASP.NET Core identity
stackOverflow 29.10.2025
Пароли мертвы. Нет, серьезно - я повторяю это уже лет пять, но теперь впервые за это время чувствую, что это не просто красивые слова. В . NET 10 команда Microsoft внедрила поддержку Passkey прямо в. . .
КиберФорум - форум программистов, компьютерный форум, программирование
Powered by vBulletin
Copyright ©2000 - 2025, CyberForum.ru