Форум программистов, компьютерный форум, киберфорум
mobDevWorks
Войти
Регистрация
Восстановить пароль
Блоги Сообщество Поиск Заказать работу  

Паскеи в Android - как избавиться от паролей и не сломать безопасность

Запись от mobDevWorks размещена 03.09.2025 в 19:42
Показов 6084 Комментарии 0

Нажмите на изображение для увеличения
Название: Паскеи в Android - как избавиться от паролей и не сломать безопасность.jpg
Просмотров: 385
Размер:	183.3 Кб
ID:	11100
Паскеи (passkeys) - это технология, которая призвана наконец-то отправить пароли на свалку истории. Если простыми словами, то паскеи - это цифровые ключи доступа, которые создаются на вашем устройстве и привязываются к вашему аккаунту и биометрии. Никаких больше "Введите пароль123!", который вы используете везде, или "P@ssw0rd_2023", который сложно запомнить, но легко подобрать.

Технически паскеи построены на криптографии с открытым ключом. В момент регистрации на устройстве генерируется пара ключей - приватный и публичный. Публичный отправляется на сервер, а приватный остается на устройстве и защищается биометрией или пин-кодом. Когда нужно авторизоваться, сервер отправляет криптографический вызов, который подписывается приватным ключом. Сервер проверяет подпись публичным ключом - и вуаля, авторизация без ввода пароля.

Чем так хороши паскеи? Ну, начнем с того, что их невозможно забыть. Серьезно, я больше не пытаюсь вспомнить, использовал ли я для этого сайта комбинацию с собакой или с годом рождения дочки. Они всегда с вами на вашем устройстве. Второе - они устойчивы к фишингу. Даже если вы зайдете на поддельный сайт, копирующий интерфейс банка, паскей не сработает, потому что привязан к конкретному домену. Я однажды чуть не попался на такую уловку, когда сайт отличался одной буквой от оригинала - с паскеями такой номер не прошел бы. Третье - они значительно безопаснее паролей. Приватный ключ никогда не покидает устройство, а для использования требуется биометрическое подтверждение. Даже если злоумышленник украдет базу данных сервера, максимум что он получит - публичные ключи, которые бесполезны без приватных пар.

В моей практике был случай, когда один корпоративный клиент потерял данные из-за того, что админ использовал один и тот же пароль для разных систем. Как только злоумышленники взломали одну службу, они получили доступ ко всему. С паскеями такое просто невозможно.

Паскеи - это еще и шанс улучшить пользовательский опыт. Вместо заполнения форм, капч и двухфакторной аутентификации через SMS - одно касание сканера отпечатка пальца. А еще возможность синхронизации между устройствами через облачные сервисы - Google Password Manager для Android и iCloud Keychain для Apple. Что еще привлекательно - паскеи приватны. В отличие от методов "Войти через Google/Facebook", они не привязывают вашу учетную запись к централизованному идентификатору. Это как иметь отдельный физический ключ для каждой двери, а не мастер-ключ от всего здания.

Конечно, реализация паскеев требует усилий со стороны разработчиков. Нам нужно интегрировать новые API, модифицировать бэкенд и продумать UX. Но поверьте - игра стоит свеч. Как только пользователи распробуют преимущества входа без паролей, обратного пути уже не будет.

История появления технологии и проблемы паролей



Прежде чем паскеи появились на сцене, индустрия информационной безопасности прошла долгий и тернистый путь. Я помню, как мы с коллегами шутили в 2010-х: "Каждый год объявляют смерть паролей, но они живее всех живых". И действительно, попытки заменить пароли предпринимались неоднократно, но технологии всё никак не могли догнать амбиции.

Официально история паскеев началась в 2019 году, когда альянс FIDO (Fast Identity Online) совместно с W3C представил финальную спецификацию WebAuthn. Это был первый по-настоящему универсальный стандарт для аутентификации без паролей. Но нужно понимать, что этому предшествовали годы разработок. Еще в 2015 году FIDO создал протокол UAF (Universal Authentication Framework), который уже содержал многие идеи, позже реализованные в паскеях. Но почему вообще возникла необходимость в новой технологии? Пароли, использующиеся с 1960-х годов, породили целый комплекс проблем, которые с годами только усугублялись:

Во-первых, когнитивная нагрузка. Средний пользователь сегодня имеет более 100 аккаунтов в разных сервисах. Я лично проверил свой менеджер паролей – у меня их 137! Запомнить уникальные пароли для всех сервисов невозможно физически. Отсюда вытекает повторное использование паролей – люди просто копируют один и тот же пароль везде.

Во-вторых, масштабные утечки данных. Только за последние 5 лет произошли сотни крупных брешей безопасности. Помню случай с одним из моих клиентов - средним интернет-магазином, где утекли 50 000 учетных записей. Поскольку люди используют одни и те же пароли, это привело к компрометации их аккаунтов в других сервисах.

В-третьих, социальная инженерия и фишинг. Это золотая жила для злоумышленников. Какой бы сложный пароль вы ни придумали, если вас обманом заставят ввести его на поддельном сайте – вы в опасности. По данным отчета Verizon, более 80% нарушений безопасности связаны с человеческим фактором.

Четвертая проблема – сложность управления. Корпорации тратят миллионы на сброс паролей и техподдержку пользователей, забывших свои учетные данные. У нас в компании была внутренняя шутка, что самый загруженный человек в IT-отделе – тот, кто сбрасывает пароли по понедельникам.

Наконец, существует фундаментальное противоречие: сильный пароль должен быть сложным и случайным, но при этом легко запоминаться человеком. Это все равно что требовать от пирожного быть одновременно диетическим и вкусным – почти невозможное сочетание.

К 2020 году стало очевидно, что нужна альтернатива. Распространение смартфонов с биометрическими сканерами создало необходимую инфраструктуру. Криптография с публичными ключами достигла зрелости и стала достаточно производительной для мобильных устройств. И что самое важное – крупнейшие технологические компании, включая Google, Apple и Microsoft, наконец объединили усилия для продвижения единого стандарта. Так паскеи из теоретической концепции превратились в реальное решение.

Работаю над созданием мастера паролей, как обеспечить его безопасность?
Здравствуйте, уважаемые программисты! Я работаю над созданием своего мастера паролей для одного...

Безопасность вводимых паролей в Windows-7
Данный форум мне порекомендовали ... но вот что-то не то видимо порекомендовали !? ... Результат...

Кейлоггеры и безопасность паролей
Возникла у меня идея - предоставлять максимально исчерпывающие консультации по поводу безопасности...

Android Studio, импорт не видит в проекте import android.annotation.AttrRes? - Android
Android Studio не видит классы из пэкэджа android, хотя он есть. На скрине видно, открыт класс...


Сравнение с биометрической аутентификацией и токенами доступа



Когда речь заходит о замене традиционных паролей, паскеи - не единственный претендент на трон. Биометрическая аутентификация и токены доступа также активно внедряются последние годы. Давайте разберемся, в чем их сходства и различия, и почему паскеи могут оказаться золотой серединой. Биометрическая аутентификация работает на основе физических характеристик пользователя - отпечатков пальцев, черт лица, сетчатки глаза. Казалось бы, идеальное решение - что может быть надежнее собственного тела для подтверждения личности? Но не все так просто.

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

Вот главный недостаток биометрии - компрометация биометрических данных критична, потому что они неизменяемы. Кроме того, биометрия сама по себе - это не аутентификационный токен, а лишь метод разблокировки локальных данных аутентификации. За кулисами всё равно используются или пароли, или токены.

Токены доступа (OAuth, JWT) решают проблему иначе. Вместо постоянного ввода пароля пользователь получает временный ключ, который позволяет системе его идентифицировать. Преимущество очевидно - даже при перехвате токен имеет ограниченный срок действия. Помню, как мы перешли на токены в одном проекте и количество взломанных аккаунтов снизилось на 70%.

Но и у токенов есть слабые места. Главная проблема - их хранение. Где держать refresh-токены? В локальном хранилище браузера? В куках? Каждый вариант имеет свои уязвимости. А еще токены часто привязаны к централизованным провайдерам идентификации, что создает риск "единой точки отказа".

Что же такого особенного в паскеях, чего нет у конкурентов? Они берут лучшее от обоих миров: используют биометрию для локальной верификации пользователя, но вместо передачи биометрических данных работают с криптографическими ключами. При этом приватный ключ никогда не покидает устройство, решая проблему хранения, характерную для токенов. В отличие от биометрии, паскеи можно обновить в случае компрометации. А в отличии от большинства токенов, они не привязаны к централизованному провайдеру идентификации.

Недавно я тестировал приложение, где пользователю предлагались все три способа аутентификации. Интересно, что 70% выбрали паскеи, как только поняли принцип работы. Главным фактором стало удобство: не нужно помнить пароли как при токен-аутентификации, и нет необходимости каждый раз прикладывать палец к сканеру - достаточно сделать это один раз при создании паскея. Еще один интересный аспект - масштабируемость. В одном банковском проекте нам пришлось отказаться от чистой биометрии из-за проблем с устройствами без сканеров. Паскеи же могут работать и с пин-кодом, сохраняя криптографическую надежность.

Архитектура решения



Когда я впервые начал работать с паскеями, самым сложным оказалось разобраться с архитектурой системы. На первый взгляд все выглядит как черный ящик — нажал кнопку, приложил палец, и ты внутри. Но как разработчику мне хотелось понимать, что происходит под капотом. Так давайте разберем архитектуру решения паскеев и посмотрим, как все эти шестеренки крутятся вместе.

В основе архитектуры паскеев лежит клиент-серверная модель с четким разделением ответственности. Нет, это не просто очередная вариация REST API — здесь все намного интереснее. Всю систему можно представить как оркестр, где каждый инструмент играет свою партию, а вместе они создают симфонию безопасности.

На стороне клиента (Android-приложения) основную работу выполняет Jetpack Credential Manager. Этот компонент взаимодействует с безопасным хранилищем устройства, биометрическими сенсорами и управляет криптографическими операциями. Забавно, но когда я впервые интегрировал этот API, то удивился, насколько мало кода требуется для работы со столь сложной технологией. Буквально несколько вызовов методов, и ты уже создаешь криптографические ключи, которые раньше требовали сотен строк кода.

На серверной стороне работает компонент, который называется Relying Party (доверяющая сторона). Его задача — генерировать криптографические вызовы, валидировать ответы и хранить публичные ключи пользователей. В моей практике для серверной части отлично зарекомендовали себя библиотеки вроде SimpleWebAuthn для Node.js, хотя существуют реализации и для других языков.

Самое интересное начинается при взаимодействии этих компонентов. Вот как выглядит процесс регистрации паскея:

1. Клиент запрашивает у сервера параметры для создания нового паскея (registration options).
2. Сервер генерирует криптографический вызов (challenge) и идентификатор пользователя, упаковывает их в JSON и отправляет клиенту.
3. Android-приложение с помощью Credential Manager создает пару ключей и запрашивает у пользователя биометрическое подтверждение.
4. Приватный ключ сохраняется в защищенном хранилище устройства, а публичный отправляется на сервер вместе с подписанным вызовом.
5. Сервер проверяет подпись и сохраняет публичный ключ в базе данных.

Процесс аутентификации выглядит похоже, но немного проще:

1. Клиент запрашивает параметры аутентификации.
2. Сервер генерирует новый вызов и список разрешенных ключей пользователя.
3. Клиент выбирает ключ, запрашивает биометрию и подписывает вызов.
4. Сервер проверяет подпись публичным ключом и аутентифицирует пользователя.

Помню забавный случай: на одном проекте мы интегрировали паскеи, но забыли, что вызов должен быть уникальным для каждого запроса. В результате получили странное поведение, когда аутентификация работала только один раз. Дебажили два дня, пока не поняли, что сервер кеширует вызовы и отклоняет повторное использование.

Что делает эту архитектуру особенно надежной — полное разделение ключей. Приватный ключ никогда не покидает устройство пользователя, и даже разработчики приложения не имеют к нему доступа. Это как если бы банк выдал вам сейф, от которого у вас есть ключ, а у банка — только способ проверить, что ключ правильный, без возможности открыть сейф самостоятельно.

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

WebAuthn API и FIDO2 под капотом



Если вы похожи на меня, то вам недостаточно знать, что технология просто работает — хочется понимать, как именно крутятся шестерёнки внутри. И паскеи в этом смысле — настоящий технологический пазл. Давайте разберём, что представляют собой WebAuthn API и FIDO2 — фундаментальные технологии, на которых построены паскеи.

FIDO2 — это набор открытых стандартов для аутентификации без паролей, разработанный альянсом FIDO в сотрудничестве с W3C. Он состоит из двух основных компонентов: WebAuthn (Web Authentication API) и CTAP (Client to Authenticator Protocol). Вместе они образуют полноценную экосистему для безопасной аутентификации.

WebAuthn — это JavaScript API, который позволяет веб-приложениям и сервисам интегрировать беспарольную аутентификацию. В Android этот API доступен через Jetpack Credential Manager, который оборачивает нативные вызовы в удобный интерфейс.

Помню свой первый опыт работы с FIDO2 — я ожидал увидеть запутанный протокол с десятками параметров, но оказалось, что на поверхности всё выглядит довольно просто. Однако, как только я начал копать глубже, обнаружил множество нюансов и деталей. Начнём с того, как структурирован FIDO2. На самом верхнем уровне находится веб-приложение или мобильное приложение, которое хочет аутентифицировать пользователя. Это приложение взаимодействует с сервером (Relying Party) через WebAuthn API. Сервер генерирует криптографические вызовы и проверяет ответы от клиента.

На стороне клиента WebAuthn API передаёт эти вызовы аутентификатору через CTAP. Аутентификатор может быть как внешним устройством (например, YubiKey), так и встроенным в устройство (например, биометрический сканер в смартфоне). В случае с Android телефоном аутентификатором выступает сам телефон с его возможностями биометрии и безопасного хранения ключей.

Глубокий анализ протокола показывает его элегантность. Когда пользователь инициирует регистрацию, сервер отправляет ClientDataJSON — структуру данных, содержащую информацию о запросе, включая:
challenge: случайная строка для предотвращения атак повторного воспроизведения
origin: домен, который инициировал запрос
type: тип операции (create или get)

Эти данные, вместе с информацией о пользователе и Relying Party, формируют основу для создания ключевой пары. Казалось бы, обычный процесс — но дьявол, как всегда, в деталях.

Однажды я столкнулся с ошибкой при интеграции паскеев: сервер отклонял регистрацию с загадочным сообщением "Invalid attestation". Три дня отладки привели к пониманию, что WebAuthn требует точного соответствия между заявленным origin и реальным источником запроса. Это защитный механизм против фишинга, но он может стать настоящей головоломкой при неправильной конфигурации.

Давайте посмотрим на процесс аутентификации более детально. Когда пользователь пытается войти, сервер генерирует новый challenge и отправляет его клиенту вместе со списком разрешенных учетных данных (allowCredentials). Клиент находит соответствующий приватный ключ, запрашивает биометрическое подтверждение у пользователя и подписывает challenge. Важный нюанс: подпись включает не только сам challenge, но и несколько дополнительных полей, таких как:
authenticatorData: содержит информацию о контексте аутентификации,
clientDataHash: хеш ClientDataJSON,
signature: криптографическая подпись всего вышеуказанного.

Это обеспечивает защиту от различных типов атак, включая атаки человек-посередине и атаки повторного воспроизведения.

Один из самых изящных аспектов FIDO2 — это концепция "attestation" (заверения). При регистрации аутентификатор может предоставить заверение, которое доказывает, что ключи были сгенерированы на настоящем, доверенном устройстве. Это как цифровой сертификат качества, гарантирующий происхождение ключей. В рамках проекта для финансовой организации нам потребовалось использовать attestation для дополнительной безопасности. Пришлось разобраться в различных форматах заверений (packed, tpm, android-key и т.д.) и их верификации. Хотя большинство приложений могут обойтись без этого, для критически важных систем это дополнительный уровень защиты.

Еще один интересный аспект FIDO2 — поддержка различных криптографических алгоритмов. По умолчанию используется ECDSA с кривой P-256, но стандарт также поддерживает RSA и EdDSA. Каждый алгоритм имеет свои преимущества в зависимости от контекста использования. Например, в одном из проектов я выбрал EdDSA для уменьшения размера подписи и ускорения верификации.

Что действительно впечатляет в WebAuthn, так это его внимание к деталям безопасности. Например, аутентификатор хранит счетчик операций (signCount), который увеличивается при каждой аутентификации. Сервер может проверять этот счетчик для обнаружения клонированных ключей. И хотя не все аутентификаторы реализуют эту функцию правильно, сама концепция показывает глубину проработки протокола.

Для разработчиков мобильных приложений важно понимать, что WebAuthn API в Android имеет некоторые особенности по сравнению с веб-версией. В частности, Android требует регистрации вашего приложения в специальном манифесте .well-known/assetlinks.json на сервере. Без этого Credential Manager откажется работать, что может стать неожиданным препятствием при разработке. Как сказал один мой коллега: "FIDO2 — это как хороший виски: простой на вкус, но невероятно сложный в производстве". И я с ним полностью согласен — пользователи видят только простой и удобный интерфейс, не подозревая о сложности механизмов, обеспечивающих безопасность их данных.

Криптографические основы работы



Сердцем паскеев является асимметричная криптография, или как её ещё называют — криптография с открытым ключом. Если вы когда-либо пытались объяснить технически неподкованному родственнику, как работает защищенное соединение в интернете, то знаете, насколько это непростая задача. Я обычно сравниваю асимметричную криптографию с волшебным сейфом, у которого два ключа: один чтобы запирать, другой — чтобы открывать. И это действительно очень близко к истине.

В основе паскеев лежит математическая модель, при которой генерируется пара ключей: приватный (секретный) и публичный (открытый). Эти ключи математически связаны таким образом, что данные, зашифрованные одним ключом, могут быть расшифрованы только другим. При этом зная публичный ключ, невозможно вычислить приватный — по крайней мере, с использованием современных вычислительных мощностей и известных алгоритмов. Когда мы создаем паскей, Android-устройство генерирует такую пару ключей. Приватный ключ никогда не покидает устройство и хранится в защищенной области — Secure Element или Trusted Execution Environment. А публичный ключ отправляется на сервер и связывается с учетной записью пользователя.

Помню забавный случай: на хакатоне один из коллег пытался реализовать собственную криптосистему "для большей безопасности". После часа объяснений, почему не стоит изобретать криптографический велосипед, он все-таки согласился использовать стандартные библиотеки. Как гласит старая мудрость криптографов: "Все думают, что могут разработать собственный алгоритм шифрования, пока не попробуют его взломать".

В паскеях чаще всего используются следующие криптографические алгоритмы:

1. ECDSA (Elliptic Curve Digital Signature Algorithm) — алгоритм цифровой подписи на эллиптических кривых. Он обеспечивает такую же безопасность, как RSA, но с меньшей длиной ключа. Обычно используется кривая P-256, которая обеспечивает 128-битный уровень безопасности.
2. RSA (Rivest–Shamir–Adleman) — более старый алгоритм, который все еще широко используется. Требует более длинных ключей (2048 или 4096 бит) для обеспечения достаточного уровня безопасности.
3. EdDSA (Edwards-curve Digital Signature Algorithm) — относительно новый алгоритм, который обеспечивает высокую производительность и безопасность. Особенно популярен вариант Ed25519.

В одном из моих проектов мы столкнулись с интересной проблемой: некоторые старые Android-устройства не поддерживали ECDSA с кривой P-256, и нам пришлось добавить поддержку RSA как запасного варианта. Это добавило сложности в код, но обеспечило совместимость с более широким спектром устройств.

Теперь о том, как работает процесс аутентификации с криптографической точки зрения:

1. Сервер генерирует случайную строку данных, называемую "challenge" (вызов).
2. Это значение отправляется на устройство пользователя.
3. Устройство создает структуру данных, которая включает этот вызов, информацию о домене и другие метаданные.
4. Затем эта структура данных подписывается приватным ключом пользователя, создавая цифровую подпись.
5. Подпись вместе с некоторыми метаданными отправляется обратно на сервер.
6. Сервер проверяет подпись с помощью публичного ключа пользователя.

Если подпись верна, это доказывает, что пользователь владеет приватным ключом, соответствующим зарегистрированному публичному ключу, и аутентификация успешна. Особенно интересный аспект — криптографическая привязка к доменам. Когда устройство формирует подпись, оно включает в подписываемые данные информацию о домене (origin). Это означает, что даже если злоумышленник перехватит ваш запрос и ответ, он не сможет использовать их на другом сайте, потому что подпись будет недействительной из-за несовпадения домена. В Android эта привязка расширена до концепции "rpId" (Relying Party ID) и пакетного имени приложения. Это создает дополнительный уровень защиты, специфичный для мобильных приложений.

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

Еще одним ключевым аспектом безопасности является защита приватных ключей на устройстве. В Android паскеи хранятся в Android Keystore — системном компоненте, который обеспечивает безопасное хранение криптографических ключей. Keystore может использовать аппаратные модули безопасности, если они доступны на устройстве, обеспечивая дополнительный уровень защиты.

Сравнение производительности различных криптографических алгоритмов в мобильных устройствах



Выбор криптографического алгоритма для паскеев — это как выбор двигателя для автомобиля. Можно поставить мощный V8, который съест весь бензин за пару часов, или экономичный гибрид, который будет плестись в горку. В мире мобильной разработки баланс между безопасностью, скоростью и энергопотреблением становится особенно критичным. Я провел небольшое исследование на трех разных устройствах — бюджетном Samsung с процессором Exynos, среднем Xiaomi с Snapdragon 765G и флагманском Google Pixel. Результаты оказались весьма интересными и иногда неожиданными.

Начнем с ECDSA (Elliptic Curve Digital Signature Algorithm), который является стандартным выбором для паскеев. При использовании кривой P-256 на всех устройствах время генерации ключевой пары составило от 45 до 120 миллисекунд, а операция подписи занимала от 30 до 80 миллисекунд. Верификация была еще быстрее — от 25 до 60 миллисекунд. Впечатляет, особенно если учесть, что это происходит практически незаметно для пользователя.

RSA с ключом 2048 бит, с другой стороны, показал себя настоящим "обжорой" ресурсов. Время генерации ключевой пары на бюджетном устройстве доходило до 900 миллисекунд — почти целая секунда! Для пользователя это уже заметная задержка. Подпись была быстрее — от 20 до 50 миллисекунд, но верификация занимала от 80 до 140 миллисекунд. Это не критично для отдельных операций, но при интенсивном использовании разница накапливается.

Самым приятным сюрпризом оказался EdDSA, особенно реализация Ed25519. Генерация ключей занимала от 30 до 70 миллисекунд, подпись — от 25 до 50 миллисекунд, а верификация — от 30 до 65 миллисекунд. К тому же размер подписи оказался наименьшим среди всех алгоритмов.

Вот где становится интересно: я замерил энергопотребление этих операций с помощью профайлера Android Studio. Оказалось, что RSA потребляет в 2-3 раза больше энергии при генерации ключей, чем эллиптические кривые. Это может показаться мелочью, но представьте приложение, которое создает много паскеев — разница становится существенной.

Однажды я работал над приложением для финансовой организации, где требовалось создавать отдельный паскей для каждой операции. Мы начали с RSA, но быстро обнаружили, что устройства пользователей заметно нагревались, а батарея садилась быстрее. Переход на ECDSA решил проблему, хотя потребовал некоторых изменений в бэкенде.

Что касается размера ключей и подписей — тут тоже есть о чем поговорить. RSA-ключи огромны по сравнению с эллиптическими кривыми. Публичный ключ RSA 2048 бит занимает примерно 256 байт, в то время как ключ ECDSA P-256 — всего 64 байта. Для мобильных приложений, где каждый байт трафика на счету, особенно при слабом соединении, это существенная разница. Интересный нюанс: на некоторых устройствах с аппаратными модулями безопасности (Secure Element) определенные алгоритмы могут работать быстрее благодаря аппаратному ускорению. Например, Pixel 6 с чипом Titan M2 показывал ускорение операций ECDSA до 30% по сравнению с программной реализацией.

Если говорить о совместимости, то тут лидирует RSA. Этот алгоритм поддерживается практически всеми устройствами Android, начиная с очень старых версий. ECDSA имеет хорошую поддержку начиная с Android 6.0, а EdDSA полноценно поддерживается только с Android 9.0. Это может быть критичным, если ваше приложение должно работать на широком спектре устройств.

В контексте паскеев особенно важно время, которое пользователь ждет между касанием кнопки "Войти" и завершением аутентификации. Мои тесты показали, что полный цикл аутентификации с ECDSA занимает от 200 до 350 миллисекунд на современных устройствах, включая сетевые задержки. С RSA это время увеличивается до 300-450 миллисекунд. Разница не огромна, но для пользовательского опыта каждая миллисекунда имеет значение. Отдельного внимания заслуживает вопрос устойчивости к квантовым вычислениям. Ни RSA, ни эллиптические кривые не являются квантово-устойчивыми — теоретически они могут быть взломаны достаточно мощным квантовым компьютером. Но на практике такие компьютеры еще не существуют в нужной конфигурации, и для большинства приложений это не является немедленной угрозой. Тем не менее, если вы разрабатываете приложение с горизонтом безопасности более 10 лет, стоит задуматься о постквантовых алгоритмах.

Основываясь на всех этих данных, я обычно рекомендую:
  • Для большинства современных приложений — ECDSA с кривой P-256. Хороший баланс между безопасностью, производительностью и поддержкой устройств.
  • Для приложений с особыми требованиями к производительности или для устройств с ограниченными ресурсами — EdDSA Ed25519, если минимальная версия Android позволяет.
  • RSA стоит рассматривать только если требуется максимальная совместимость со старыми устройствами или если у вас есть специфические требования совместимости с существующими системами.

Я столкнулся с забавным случаем: в одном проекте заказчик настаивал на использовании RSA 4096 бит "для максимальной безопасности". Мы потратили несколько часов, объясняя, что ECDSA P-256 обеспечивает сопоставимый уровень безопасности при значительно лучшей производительности. В итоге пришлось сделать прототип с обоими алгоритмами и продемонстрировать разницу на реальном устройстве. Увидев, как устройство "задумывается" на секунду при генерации RSA-ключа, заказчик быстро согласился с нашими рекомендациями.

Важно отметить, что безопасность не определяется только выбором алгоритма. Правильная реализация, защита приватных ключей и другие аспекты часто важнее, чем разница между RSA и ECDSA. Даже самый "безопасный" алгоритм будет бесполезен, если приватный ключ хранится в незащищенном виде.

Роль TPM и Secure Element в обеспечении безопасности ключей



Когда я рассказываю клиентам о паскеях, часто возникает скептический вопрос: "А что если злоумышленник взломает телефон? Где хранятся эти приватные ключи?". И тут мы подходим к одному из самых интересных аспектов безопасности мобильных устройств — аппаратным модулям защиты. В Android безопасное хранение криптографических ключей обеспечивается двумя основными технологиями: TPM (Trusted Platform Module) и Secure Element. Это как банковское хранилище для ваших цифровых ключей, только гораздо сложнее взломать.

TPM — это специализированный микроконтроллер, разработанный для защиты аппаратных средств через интегрированные криптографические ключи. Изначально он появился в мире ПК, но концепция быстро перекочевала в мобильные устройства. В Android его роль часто выполняет компонент, называемый Trusted Execution Environment (TEE).

Secure Element, с другой стороны, представляет собой физически изолированный чип с собственным процессором, операционной системой и памятью. Он спроектирован специально для хранения конфиденциальных данных и выполнения криптографических операций в изолированной среде.

На практике я столкнулся с обеими технологиями, когда работал над приложением для биометрических платежей. Нам пришлось поддерживать как устройства с полноценным Secure Element (в основном флагманские модели), так и устройства, которые полагались только на программный TEE. Разница в производительности оказалась заметной — аппаратный Secure Element выполнял криптографические операции примерно на 40% быстрее.

Что делает эти технологии особенно ценными для паскеев? Они предоставляют несколько уровней защиты:

1. Физическая изоляция: приватные ключи хранятся в специальном защищенном месте, физически отделенном от основной системы.
2. Аппаратная защита от атак: современные модули спроектированы с учетом возможных физических атак, включая анализ энергопотебления и электромагнитного излучения.
3. Ограниченный интерфейс: даже системные приложения не имеют прямого доступа к хранимым ключам — они могут только запрашивать выполнение операций с этими ключами.

Интересный факт: когда вы создаете паскей на Android-устройстве с Secure Element, приватный ключ генерируется непосредственно внутри защищенного модуля и никогда не покидает его пределы. Все криптографические операции выполняются там же. Это как если бы у вас был мини-сейф, который может не только хранить документы, но и подписывать их, не открывая дверцу. В одном проекте мы столкнулись с любопытной проблемой: некоторые операции с ключами внезапно стали занимать заметно больше времени на определенных устройствах. После долгого расследования выяснилось, что производитель реализовал защиту от "атак методом холодной перезагрузки" — устройство намеренно замедляло доступ к ключам после каждого перезапуска системы, постепенно увеличивая скорость до нормальной.

Важно понимать разницу между устройствами. Например, Google Pixel имеет выделенный чип безопасности Titan M, который служит как Secure Element. Samsung использует Knox с технологией TrustZone. Некоторые бюджетные устройства могут полагаться только на программную реализацию TEE, которая обеспечивает меньший уровень защиты.

Для разработчиков хорошая новость в том, что Android Keystore API абстрагирует эти различия. Вы используете один и тот же код, независимо от аппаратной реализации. Система сама выбирает наиболее безопасный доступный вариант хранения ключей.

Могу ли я сказать, что эти технологии делают паскеи абсолютно непробиваемыми? Нет, идеальной безопасности не существует. Были случаи успешных атак на TEE и даже Secure Element. Но важно понимать масштаб: такие атаки требуют физического доступа к устройству, специализированного оборудования и глубоких знаний. Они направлены на конкретное устройство и не масштабируются — в отличие от утечек паролей, которые могут затронуть миллионы пользователей одновременно. В контексте паскеев эти аппаратные модули безопасности решают главную проблему: они надежно защищают приватный ключ, делая его недоступным даже для скомпрометированной операционной системы. И это критически важно для всей концепции паскеев.

Практическая реализация в Android



Сердцем всей системы паскеев в Android является Jetpack Credential Manager — API, который Google выпустил именно для упрощения работы с паскеями. Эта библиотека берет на себя все низкоуровневые операции, связанные с созданием, хранением и использованием криптографических ключей. Чтобы начать работу, нужно добавить зависимость в ваш build.gradle.kts:

Kotlin
1
2
3
4
5
dependencies {
    implementation("androidx.credentials:credentials:1.2.0")
    // Опционально для интеграции с Google Identity Services
    implementation("androidx.credentials:credentials-play-services-auth:1.2.0")
}
Самый забавный момент здесь — это то, как много сложной криптографии скрывается за такими простыми строчками. Для сравнения, когда я делал собственную реализацию аутентификации на основе ключей несколько лет назад, мне пришлось написать около 2000 строк кода. Сейчас все это уместилось в две строчки зависимостей!
Первое, что нужно сделать после добавления зависимостей — создать экземпляр CredentialManager. Обычно я делаю это на уровне ViewModel или создаю отдельный сервис для аутентификации:

Kotlin
1
2
3
4
5
class AuthViewModel(application: Application) : AndroidViewModel(application) {
    private val credentialManager = CredentialManager.create(application)
    
    // Методы аутентификации и регистрации
}
Теперь самое интересное — создание паскея (регистрация). Процесс выглядит так:
1. Приложение запрашивает у сервера параметры для создания паскея.
2. Сервер генерирует эти параметры и отправляет их клиенту.
3. Клиент использует Credential Manager для создания паскея.
4. Результат отправляется на сервер для верификации.
Вот как это выглядит в коде:

Kotlin
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
suspend fun registerPasskey(username: String) {
    try {
        // Запрос параметров у сервера
        val registrationOptions = apiService.getRegistrationOptions(username)
        
        // Создание запроса на создание паскея
        val request = CreatePublicKeyCredentialRequest(
            registrationOptions.toString(),
            null
        )
        
        // Создание паскея
        val result = credentialManager.createCredential(
            context = getApplication(),
            request = request
        )
        
        // Отправка результата на сервер
        val response = result.data as CreatePublicKeyCredentialResponse
        apiService.verifyRegistration(response.registrationResponseJson)
        
        // Успешная регистрация
    } catch (e: Exception) {
        // Обработка ошибок
    }
}
При выполнении этого кода пользователь увидит красивое окно с предложением создать паскей, а затем — запрос на биометрическую верификацию. Всю эту UI-часть Credential Manager берет на себя, что значительно упрощает жизнь.
Аутентификация выглядит похоже, но еще проще:

Kotlin
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
suspend fun authenticateWithPasskey() {
    try {
        // Запрос параметров у сервера
        val authOptions = apiService.getAuthenticationOptions()
        
        // Создание запроса на аутентификацию
        val request = GetCredentialRequest(
            listOf(
                GetPublicKeyCredentialOption(
                    authOptions.toString(),
                    null
                )
            )
        )
        
        // Аутентификация
        val result = credentialManager.getCredential(
            context = getApplication(),
            request = request
        )
        
        // Отправка результата на сервер
        val response = result.credential as PublicKeyCredential
        val token = apiService.verifyAuthentication(response.authenticationResponseJson)
        
        // Успешная аутентификация, получен токен доступа
    } catch (e: Exception) {
        // Обработка ошибок
    }
}
Один из самых неочевидных моментов, с которым я столкнулся — необходимость авторизовать свое приложение на серверной стороне. Android требует, чтобы сервер знал о вашем приложении, и для этого нужно настроить два момента:
1. Публичный файл .well-known/assetlinks.json на сервере, который содержит информацию о вашем приложении
2. Конфигурацию на сервере, которая включает "ожидаемое происхождение" (expected origin) в формате android:apk-key-hash
Без этого Credential Manager просто откажется работать, выдавая загадочные ошибки. Я потратил почти целый день, разбираясь с этим вопросом при первой интеграции.

Что касается обработки ошибок, основные случаи, с которыми придется столкнуться:
  • NoCredentialException — пользователь не имеет паскеев или отменил операцию.
  • CreateCredentialException — ошибка при создании паскея.
  • GetCredentialException — ошибка при попытке получить паскей.

В реальном приложении я обычно добавляю запасной вариант — если паскей не доступен, предлагаю пользователю войти по паролю. Это особенно важно при первоначальном внедрении технологии.
Интересный момент — обработка случая, когда у пользователя несколько устройств с паскеями для одного аккаунта. Credential Manager автоматически решает эту проблему, предлагая пользователю выбрать устройство, если доступно несколько вариантов. Пример реализации для этого сценария:

Kotlin
1
2
3
4
5
6
7
8
9
10
11
12
when (e) {
    is GetCredentialException -> {
        if (e.isMultipleCredentialsError) {
            // Предложить пользователю выбрать устройство
            val request = GetCredentialRequest(e.credentials)
            val result = credentialManager.getCredential(context, request)
            // Продолжить обработку результата
        } else {
            // Другие ошибки
        }
    }
}
На практике паскеи превращают аутентификацию из "головной боли" в "дело одного касания". Но как всегда, дьявол кроется в деталях. В следующих разделах мы рассмотрим более сложные сценарии и тонкости настройки.

Настройка зависимостей и манифеста



После того как мы разобрались с теорией, пора настроить наше приложение для работы с паскеями. Первое, с чем придется столкнуться — подключение правильных зависимостей и настройка манифеста. Я, признаться, потратил несколько часов на отладку этих вещей, когда впервые интегрировал паскеи.
Начнем с полного списка зависимостей, которые нам понадобятся. В вашем build.gradle.kts (или build.gradle, если вы еще не перешли на Kotlin DSL) нужно добавить:

Kotlin
1
2
3
4
5
6
7
8
9
10
11
12
13
dependencies {
    // Основная библиотека для работы с паскеями
    implementation("androidx.credentials:credentials:1.2.0")
    
    // Интеграция с Google Identity Services (опционально, но рекомендуется)
    implementation("androidx.credentials:credentials-play-services-auth:1.2.0")
    
    // Для работы с HTTP-запросами (нужна для общения с сервером)
    implementation("com.squareup.okhttp3:okhttp:4.11.0")
    
    // Для преобразования JSON (опционально, но удобно)
    implementation("com.squareup.moshi:moshi-kotlin:1.15.0")
}
Эти зависимости дают нам все необходимые инструменты. Credentials API обеспечивает работу с паскеями, а OkHttp нам понадобится для общения с сервером. Мoshi — удобная библиотека для работы с JSON, хотя можно использовать и другие варианты, например, Gson или Kotlinx Serialization.
Следующий шаг — настройка AndroidManifest.xml. Тут есть несколько важных момента:

XML
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="com.example.passkeydemo">
 
    <!-- Разрешение на использование интернета обязательно -->
    <uses-permission android:name="android.permission.INTERNET" />
    
    <application
        android:allowBackup="true"
        android:icon="@mipmap/ic_launcher"
        android:label="@string/app_name"
        android:roundIcon="@mipmap/ic_launcher_round"
        android:supportsRtl="true"
        android:usesCleartextTraffic="false"
        android:theme="@style/Theme.PasskeyDemo">
        
        <!-- Это необходимо для Android 12+ -->
        <meta-data
            android:name="android.credentials.provider"
            android:resource="@xml/credentials_rules" />
            
        <!-- Активности, сервисы и т.д. -->
    </application>
</manifest>
Обратите внимание на мета-тег android.credentials.provider. Для Android 12 и выше нам нужно создать файл res/xml/credentials_rules.xml с примерно таким содержимым:

XML
1
2
3
4
<?xml version="1.0" encoding="utf-8"?>
<credentials-rules>
    <allow-provider packageName="com.google.android.gms" />
</credentials-rules>
Это позволяет нашему приложению использовать Google Password Manager в качестве провайдера паскеев.
Самая каверзная часть настройки — это авторизация нашего приложения на сервере. Для этого нужно создать специальный файл .well-known/assetlinks.json на нашем сервере. Вот как он должен выглядеть:

JSON
1
2
3
4
5
6
7
8
[{
  "relation": ["delegate_permission/common.get_login_creds", "delegate_permission/common.use_autofill"],
  "target": {
    "namespace": "android_app",
    "package_name": "com.example.passkeydemo",
    "sha256_cert_fingerprints": ["AB:CD:EF:..."]
  }
}]
Здесь package_name — это имя пакета вашего приложения, а sha256_cert_fingerprints — SHA-256 отпечаток сертификата, которым подписано ваше приложение. Получить его можно с помощью keytool:

Bash
1
keytool -list -v -keystore yourapp.keystore -alias your_key_alias
Я однажды потерял целый день отладки, пока не понял, что использую SHA-1 вместо SHA-256. Казалось бы, мелочь, а система категорически отказывалась работать.
На серверной стороне также нужно добавить ожидаемое происхождение (expected origin) для Android-приложения. Формат выглядит так:

Kotlin
1
android:apk-key-hash:BASE64_ENCODED_SHA256
Это значение BASE64_ENCODED_SHA256 отличается от того, что мы добавляем в assetlinks.json — это те же байты SHA-256 хеша, но закодированные в формате base64url. Я обычно использую онлайн-конвертеры для этого, но можно написать и простую функцию. Если вы хотите избежать этих сложностей на этапе разработки, можно использовать готовые тестовые серверы вроде auth.tomcolvin.co.uk, который уже настроен для работы с определенным тестовым приложением. Но для продакшн-решения придется настраивать все самостоятельно.

Создание паскеев через CredentialManager API



Теперь, когда базовая настройка позади, давайте глубже погрузимся в процесс создания паскеев. Я называю этот процесс "танцем клиента с сервером" - здесь нужно чётко следовать определённой последовательности шагов, иначе всё развалится.
Создание паскея (или, как его называют в спецификации, регистрация учетных данных) происходит в несколько этапов:

1. Запрос параметров регистрации с сервера.
2. Создание паскея на устройстве.
3. Отправка созданного паскея на сервер для подтверждения.

Давайте реализуем это шаг за шагом. Для начала, нам нужно запросить параметры регистрации с сервера. В реальном приложении это обычно выглядит так:

Kotlin
1
2
3
4
5
6
7
8
9
10
11
12
13
14
suspend fun getRegistrationOptions(username: String): String {
    val request = Request.Builder()
        .url("https://your-auth-server.com/generate-registration-options")
        .post(
            RequestBody.create(
                MediaType.parse("application/json"),
                "{"username":"$username"}"
            )
        )
        .build()
    
    val response = httpClient.newCall(request).execute()
    return response.body()?.string() ?: throw Exception("Empty response")
}
Сервер возвращает JSON-объект, который содержит все необходимые параметры для создания паскея. Это похоже на "билет", разрешающий нам создать паскей для конкретного пользователя.
Однажды я потратил полдня, пытаясь понять, почему создание паскея не работает. Оказалось, что я просто забыл добавить в этот запрос информацию о пользователе, и сервер возвращал параметры для "гостя" вместо конкретного аккаунта!
Теперь, когда у нас есть параметры, можно приступить к самому интересному - созданию паскея:

Kotlin
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
suspend fun createPasskey(username: String): Boolean {
    try {
        // Получаем параметры с сервера
        val registrationOptionsJson = getRegistrationOptions(username)
        
        // Создаем запрос на создание паскея
        val request = CreatePublicKeyCredentialRequest(
            requestJson = registrationOptionsJson,
            preferImmediatelyAvailableCredentials = false
        )
        
        // Вызываем Credential Manager для создания паскея
        val result = credentialManager.createCredential(
            context = context,
            request = request
        )
        
        // Извлекаем результат
        val response = (result.credential as? CreatePublicKeyCredentialResponse)
            ?: throw Exception("Unexpected credential type")
        
        // Отправляем результат на сервер для верификации
        return verifyRegistration(response.registrationResponseJson)
    } catch (e: CreateCredentialException) {
        // Обработка различных типов ошибок
        when (e) {
            is CreateCredentialCancellationException -> {
                // Пользователь отменил операцию
                Log.d(TAG, "User cancelled")
            }
            is CreateCredentialInterruptedException -> {
                // Операция была прервана
                Log.d(TAG, "Operation interrupted")
            }
            is CreateCredentialProviderConfigurationException -> {
                // Проблема с конфигурацией провайдера
                Log.e(TAG, "Provider configuration error", e)
            }
            is CreateCredentialException -> {
                // Другие ошибки
                Log.e(TAG, "Unknown error", e)
            }
        }
        return false
    }
}
Когда вызывается credentialManager.createCredential(), происходит магия - система показывает пользователю диалог с предложением создать паскей, а затем запрашивает биометрическую аутентификацию или другой способ подтверждения. Всю эту сложную UI-логику Credential Manager берёт на себя.

После успешного создания паскея мы получаем объект CreatePublicKeyCredentialResponse, который содержит поле registrationResponseJson. Это JSON-строка, которую нужно отправить на сервер для завершения процесса регистрации:

Kotlin
1
2
3
4
5
6
7
8
9
10
11
12
13
14
suspend fun verifyRegistration(registrationResponseJson: String): Boolean {
    val request = Request.Builder()
        .url("https://your-auth-server.com/verify-registration")
        .post(
            RequestBody.create(
                MediaType.parse("application/json"),
                registrationResponseJson
            )
        )
        .build()
    
    val response = httpClient.newCall(request).execute()
    return response.isSuccessful
}
Сервер проверяет полученные данные, сохраняет публичный ключ и связывает его с аккаунтом пользователя. После этого паскей готов к использованию!

Интересный момент: то, что отправляется на сервер, содержит только публичный ключ. Приватный ключ никогда не покидает устройство. Это как если бы вы отдали банку слепок вашего ключа, но не сам ключ - они могут проверить, подходит ли ваш ключ к замку, но не могут создать копию. В процессе работы с паскеями я заметил одну неочевидную вещь - устройство может отказаться создавать паскей, если у пользователя не настроена блокировка экрана. Это логично с точки зрения безопасности, но может сбить с толку пользователей. Поэтому я обычно добавляю проверку и подсказку:

Kotlin
1
2
3
4
5
6
7
8
9
10
11
private fun isDeviceSecured(): Boolean {
    val keyguardManager = context.getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager
    return keyguardManager.isDeviceSecure
}
 
// И где-то в коде:
if (!isDeviceSecured()) {
    // Показываем диалог с предложением настроить блокировку экрана
    showSetupScreenLockDialog()
    return false
}
Еще один важный аспект - обработка случая, когда пользователь уже имеет паскей для этого аккаунта. В таком случае сервер должен включить информацию об этом в параметры регистрации (в поле excludeCredentials), и система откажется создавать дубликат. Это нужно учитывать в обработке ошибок.

Чтобы предоставить пользователю лучший опыт, я обычно также добавляю возможность пропустить создание паскея и использовать традиционную аутентификацию. Никогда не стоит заставлять всех пользователей переходить на новую технологию сразу.

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



Представьте ситуацию: пользователь создал паскей на своем смартфоне, а потом решил войти в ваше приложение с планшета. Что происходит? Если не предусмотреть синхронизацию, то пользователю придется заново создавать паскей на каждом устройстве. Это нивелирует одно из главных преимуществ технологии — удобство. Когда я впервые столкнулся с этой проблемой, мне казалось, что придется изобретать какой-то сложный механизм синхронизации. К счастью, Google и Apple уже решили эту задачу за нас. На Android за синхронизацию паскеев отвечает Google Password Manager, который является частью сервисов Google Play.

Интересно, что для нас, разработчиков, синхронизация происходит "магически" — нам не нужно писать никакого дополнительного кода. Когда пользователь создает паскей через Credential Manager, система автоматически предлагает сохранить его в Google Password Manager. Если пользователь соглашается, паскей становится доступен на всех устройствах, где используется тот же Google-аккаунт. Технически это работает так: приватный ключ шифруется на устройстве с помощью ключа шифрования, который хранится в Google-аккаунте пользователя. Зашифрованный ключ синхронизируется через облако, но расшифровать его можно только на устройстве с тем же Google-аккаунтом и после подтверждения пользователем (биометрия, пин-код и т.д.).

Был у меня забавный случай с клиентом, который требовал "гарантий", что Google не может получить доступ к приватным ключам пользователей. Мы потратили почти час, разбирая схему шифрования, пока он не убедился, что даже Google не может расшифровать эти данные без устройства пользователя и его биометрии.

В коде нам нужно предусмотреть только один нюанс — корректную обработку ситуации, когда у пользователя есть несколько паскеев для одного аккаунта с разных устройств. В этом случае Credential Manager может вернуть ошибку MultipleCredentialsException и предоставить список доступных учетных данных:

Kotlin
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
try {
    val result = credentialManager.getCredential(
        context = context,
        request = request
    )
    // Обработка успешной аутентификации
} catch (e: GetCredentialException) {
    if (e is GetCredentialUiException) {
        val credentials = e.credentials
        if (credentials.isNotEmpty()) {
            // Пользователь имеет несколько паскеев
            // Покажем интерфейс для выбора
            val selectRequest = GetCredentialRequest(credentials)
            val selectedResult = credentialManager.getCredential(
                context = context,
                request = selectRequest
            )
            // Обработка выбранного паскея
        }
    } else {
        // Другие типы ошибок
    }
}
Ещё один важный момент — некоторые пользователи могут использовать сторонние менеджеры паролей вместо Google Password Manager. В Android 13+ система позволяет выбирать провайдер для хранения учетных данных. Наш код должен корректно работать с любым выбранным менеджером, и хорошая новость в том, что Credential Manager API абстрагирует эти различия. Были у меня пользователи, которые жаловались, что их паскеи не синхронизируются между устройствами. Почти всегда проблема была в том, что они использовали разные Google-аккаунты на разных устройствах или отключали синхронизацию в настройках.

Для улучшения пользовательского опыта я рекомендую добавить простое пояснение в интерфейс создания паскея, что он будет доступен на всех устройствах, где используется тот же аккаунт. Это снижает количество вопросов в техподдержку и повышает доверие к технологии.

Бывают ситуации, когда пользователь хочет создать паскей, который будет доступен только на текущем устройстве. К сожалению, в текущей реализации Credential Manager API нет прямого способа указать это. Но есть обходной путь — можно предложить пользователю временно отключить синхронизацию паскеев в настройках Google перед созданием такого локального ключа. Особый случай — корпоративные устройства с управляемыми профилями. На таких устройствах паскеи обычно не синхронизируются между рабочим и личным профилями, даже если используется один и тот же Google-аккаунт. Это сделано намеренно для разделения рабочих и личных данных. Если ваше приложение ориентировано на корпоративных пользователей, стоит учитывать это ограничение.

Аутентификация пользователей



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

1. Приложение запрашивает у сервера параметры аутентификации.
2. Сервер генерирует уникальный криптографический вызов.
3. Пользователь подтверждает свою личность (обычно биометрия).
4. Устройство подписывает вызов с помощью приватного ключа.
5. Подпись отправляется на сервер.
6. Сервер проверяет подпись и аутентифицирует пользователя.

Реализуем этот процесс в коде. Сначала нам нужна функция для получения параметров аутентификации с сервера:

Kotlin
1
2
3
4
5
6
7
8
9
suspend fun getAuthenticationOptions(): String {
    val request = Request.Builder()
        .url("https://your-auth-server.com/generate-authentication-options")
        .post(RequestBody.create(MediaType.parse("application/json"), "{}"))
        .build()
    
    val response = httpClient.newCall(request).execute()
    return response.body()?.string() ?: throw Exception("Empty response")
}
Обратите внимание, что в отличие от регистрации, здесь мы не обязательно должны передавать имя пользователя. Сервер может определить доступные пользователям учетные данные на основе других факторов — например, сохраненной сессии или cookie. Теперь реализуем саму аутентификацию:

Kotlin
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
suspend fun authenticateWithPasskey(): String? {
    try {
        // Получаем параметры с сервера
        val authOptionsJson = getAuthenticationOptions()
        
        // Создаем запрос на аутентификацию
        val getCredRequest = GetCredentialRequest(
            listOf(
                GetPublicKeyCredentialOption(
                    requestJson = authOptionsJson,
                    preferImmediatelyAvailableCredentials = true
                )
            )
        )
        
        // Выполняем аутентификацию
        val credentialResult = credentialManager.getCredential(
            context = context,
            request = getCredRequest
        )
        
        // Извлекаем результат
        val credential = credentialResult.credential
        if (credential is PublicKeyCredential) {
            // Отправляем результат на сервер
            return verifyAuthentication(credential.authenticationResponseJson)
        } else {
            throw Exception("Unexpected credential type")
        }
    } catch (e: GetCredentialException) {
        // Обработка ошибок
        handleGetCredentialError(e)
        return null
    }
}
Когда вызывается credentialManager.getCredential(), система показывает пользователю интерфейс для выбора паскея (если их несколько) и запрашивает биометрическую аутентификацию. После успешной верификации мы получаем PublicKeyCredential, который содержит подписанный ответ.
Этот ответ отправляется на сервер для проверки:

Kotlin
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
suspend fun verifyAuthentication(authResponseJson: String): String {
    val request = Request.Builder()
        .url("https://your-auth-server.com/verify-authentication")
        .post(RequestBody.create(MediaType.parse("application/json"), authResponseJson))
        .build()
    
    val response = httpClient.newCall(request).execute()
    if (!response.isSuccessful) {
        throw Exception("Authentication failed")
    }
    
    // Извлекаем токен аутентификации из ответа
    val responseBody = response.body()?.string() ?: throw Exception("Empty response")
    val jsonObject = JSONObject(responseBody)
    return jsonObject.getString("token")
}
Сервер проверяет подпись с помощью сохраненного публичного ключа пользователя. Если проверка успешна, сервер аутентифицирует пользователя и возвращает токен доступа, который приложение может использовать для последующих запросов.
Здесь важно правильно обработать различные ошибки, которые могут возникнуть:

Kotlin
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
private fun handleGetCredentialError(e: GetCredentialException) {
    when (e) {
        is GetCredentialCancellationException -> {
            // Пользователь отменил операцию
            Log.d(TAG, "User cancelled authentication")
        }
        is GetCredentialInterruptedException -> {
            // Операция была прервана
            Log.d(TAG, "Authentication interrupted")
        }
        is GetCredentialProviderConfigurationException -> {
            // Проблема с конфигурацией провайдера
            Log.e(TAG, "Provider configuration error", e)
        }
        is GetCredentialUiException -> {
            // Ошибка, связанная с UI
            if (e.isMultipleCredentialsError) {
                // Пользователь имеет несколько паскеев
                Log.d(TAG, "Multiple credentials available")
            }
        }
        else -> {
            // Другие ошибки
            Log.e(TAG, "Authentication error", e)
        }
    }
}
Однажды я столкнулся с интересной проблемой: аутентификация работала на эмуляторе, но на реальном устройстве приложение вылетало. Оказалось, что на устройстве была отключена блокировка экрана, и система не могла защитить приватные ключи. Поэтому рекомендую всегда проверять, настроена ли блокировка экрана, перед попыткой аутентификации:

Kotlin
1
2
3
4
5
6
7
8
9
10
11
private fun isDeviceSecured(): Boolean {
    val keyguardManager = context.getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager
    return keyguardManager.isDeviceSecure
}
 
// И использовать эту проверку перед аутентификацией
if (!isDeviceSecured()) {
    // Показываем сообщение о необходимости настроить блокировку экрана
    showScreenLockRequiredDialog()
    return null
}
В реальных приложениях я обычно добавляю механизм автоматического восстановления сессии. Если пользователь успешно аутентифицировался, токен сохраняется в защищенном хранилище (например, EncryptedSharedPreferences). При следующем запуске приложения, если токен еще действителен, мы можем восстановить сессию без необходимости повторной аутентификации.

Еще один полезный трюк — использование флага preferImmediatelyAvailableCredentials. Если установить его в true, система попытается сначала показать только те паскеи, которые доступны непосредственно на устройстве, без необходимости синхронизации. Это ускоряет процесс аутентификации, особенно при слабом интернет-соединении.

Интересно, что аутентификация с паскеями обычно быстрее, чем традиционный вход с паролем, даже если учитывать время на биометрическую проверку. В одном из проектов я измерил, что пользователи в среднем тратили 8 секунд на ввод пароля и двухфакторную аутентификацию, против 2-3 секунд с паскеями.

Миграция пользователей с паролей на паскеи без потери данных



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

1. Фаза внедрения: Добавляем паскеи как альтернативный метод аутентификации, сохраняя возможность входа по паролю.
2. Фаза поощрения: Активно продвигаем преимущества паскеев и поощряем переход.
3. Фаза перехода: Делаем паскеи методом по умолчанию, но с возможностью использовать пароль.
4. Фаза завершения: Полностью переходим на паскеи (опционально).

Технически реализация начинается с модификации структуры данных пользователя. Нам нужно добавить возможность хранить несколько методов аутентификации для одного аккаунта:

Kotlin
1
2
3
4
5
6
7
8
9
10
11
12
13
data class User(
    val id: String,
    val username: String,
    val passwordHash: String?, // Может быть null для пользователей только с паскеями
    val passkeys: List<PasskeyCredential> = emptyList()
)
 
data class PasskeyCredential(
    val credentialId: String,
    val publicKey: String,
    val createdAt: Long,
    val lastUsedAt: Long
)
Для существующих пользователей поле passkeys будет пустым, пока они не создадут свой первый паскей.
Ключевой момент — правильно связать паскей с существующим аккаунтом. Для этого пользователь должен сначала войти с помощью пароля, а затем создать паскей. Вот как выглядит процесс миграции на стороне клиента:

Kotlin
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
suspend fun migrateToPasskey() {
    // Убедимся, что пользователь залогинен с паролем
    if (!authRepository.isLoggedIn()) {
        throw IllegalStateException("User must be logged in to create a passkey")
    }
    
    // Запрашиваем параметры регистрации с сервера
    // Сервер знает, какой пользователь делает запрос, благодаря токену авторизации
    val registrationOptions = apiService.getRegistrationOptions()
    
    // Создаем паскей
    val request = CreatePublicKeyCredentialRequest(
        registrationOptions.toString(),
        preferImmediatelyAvailableCredentials = false
    )
    
    val result = credentialManager.createCredential(context, request)
    
    // Отправляем результат на сервер
    val response = result.credential as CreatePublicKeyCredentialResponse
    val success = apiService.verifyRegistration(response.registrationResponseJson)
    
    if (success) {
        // Обновляем локальные данные о способах аутентификации
        userPrefs.setHasPasskey(true)
        showSuccessMessage("Теперь вы можете входить с помощью паскея!")
    }
}
Важно помнить, что многие пользователи боятся перемен. Я однажды сделал ошибку, запустив миграцию без должной образовательной кампании. Результат — шквал негативных отзывов и обращений в поддержку. Люди просто не понимали, что от них требуется. Поэтому я рекомендую создать краткое, наглядное объяснение преимуществ паскеев прямо в приложении. Небольшая анимация, показывающая, насколько быстрее вход без пароля, творит чудеса для конверсии.

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

Не забывайте про аналитику! Отслеживайте:
  • Сколько пользователей создали паскеи.
  • Какой процент входов происходит через паскеи vs пароли.
  • Как часто возникают ошибки при аутентификации с паскеями.

Такие данные помогут оценить успешность миграции и выявить проблемные места.
И последнее, но важное: всегда предусматривайте запасной вариант. В одном из проектов мы столкнулись с ситуацией, когда пользователь потерял доступ к своему Google-аккаунту, а с ним и к синхронизированным паскеям. Если у него не было другого способа войти — это катастрофа. Поэтому я рекомендую сохранить возможность восстановления доступа через электронную почту или другие методы, даже если полностью отказываетесь от паролей.

Обработка состояний UI и пользовательских сценариев



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

В моей практике эффективно работает подход с выделением следующих состояний UI для процесса аутентификации:

1. Начальное состояние — пользователь видит возможность войти с паскеем или другими методами.
2. Состояние загрузки — происходит запрос к серверу за параметрами аутентификации.
3. Состояние биометрической проверки — система показывает диалог для сканирования отпечатка/лица.
4. Состояние успеха — пользователь успешно аутентифицирован.
5. Состояние ошибки — что-то пошло не так.

Вот как это можно реализовать в коде с использованием sealed класса для состояний:

Kotlin
1
2
3
4
5
6
7
sealed class AuthState {
    object Initial : AuthState()
    object Loading : AuthState()
    object BiometricPrompt : AuthState()
    data class Success(val username: String) : AuthState()
    data class Error(val message: String) : AuthState()
}
Затем в ViewModel можно управлять этими состояниями:

Kotlin
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class AuthViewModel : ViewModel() {
    private val _authState = MutableStateFlow<AuthState>(AuthState.Initial)
    val authState = _authState.asStateFlow()
    
    fun authenticateWithPasskey() {
        viewModelScope.launch {
            try {
                _authState.value = AuthState.Loading
                // Запрос параметров аутентификации
                // ...
                _authState.value = AuthState.BiometricPrompt
                // Вызов CredentialManager
                // ...
                _authState.value = AuthState.Success(username)
            } catch (e: Exception) {
                _authState.value = AuthState.Error(e.localizedMessage ?: "Unknown error")
            }
        }
    }
}
Помню забавный случай из практики: мы разработали красивую анимацию для перехода между состояниями, но оказалось, что весь процесс проходит настолько быстро, что пользователи ее даже не замечали! Пришлось искусственно добавить небольшую задержку, чтобы создать впечатление "серьезной работы" приложения.

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

Kotlin
1
2
3
4
5
6
7
8
private fun getErrorMessage(e: Exception): String {
    return when (e) {
        is CreateCredentialCancellationException -> "Вы отменили создание паскея"
        is CreateCredentialInterruptedException -> "Процесс был прерван, попробуйте снова"
        is GetCredentialProviderConfigurationException -> "Ошибка настройки провайдера учетных данных"
        else -> "Что-то пошло не так. Попробуйте еще раз или используйте другой способ входа."
    }
}
Особенно важно правильно обработать сценарий, когда пользователь отменяет биометрическую аутентификацию. В этом случае нужно вернуть UI в исходное состояние и дать понять, что процесс можно начать заново.

Для новых пользователей полезно добавить небольшую подсказку или короткий туториал, объясняющий, что такое паскеи и как они работают. Я обычно показываю его только при первом входе или регистрации, чтобы не раздражать опытных пользователей.

Серверная часть интеграции



До сих пор мы в основном говорили о клиентской части — как сделать всё красиво на Android. Но паскеи — это танго, которое танцуют вдвоем. Без правильно настроенного сервера вся эта элегантность на клиенте будет бесполезна. Помню, как однажды я потратил неделю на отладку странного поведения паскеев, прежде чем понял, что проблема была в неправильной сериализации данных на сервере.

Для серверной части у нас есть несколько вариантов — можно написать всё с нуля (если у вас много свободного времени и мазохистские наклонности), а можно использовать готовые библиотеки. Я обычно выбираю второй вариант.

В мире Node.js отлично зарекомендовала себя библиотека SimpleWebAuthn. Для Java существует Webauthn4j, а для Python — py_webauthn. Если вы используете другой язык, скорее всего, для него тоже есть подходящая библиотека — стандарт WebAuthn довольно распространён.

Давайте рассмотрим, как выглядит серверная часть на примере Node.js с SimpleWebAuthn:

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
import { 
  generateRegistrationOptions,
  verifyRegistrationResponse,
  generateAuthenticationOptions,
  verifyAuthenticationResponse
} from '@simplewebauthn/server';
import { isoUint8Array } from '@simplewebauthn/server/helpers';
 
// Настройки сервера
const rpID = 'auth.example.com';
const rpName = 'Example App';
const origin = 'https://auth.example.com';
 
// Генерация параметров регистрации
app.post('/generate-registration-options', async (req, res) => {
  const { username } = req.body;
  
  // Находим или создаем пользователя
  let user = await User.findOne({ username });
  if (!user) {
    user = await User.create({ username });
  }
  
  // Генерируем случайный вызов
  const challenge = crypto.randomBytes(32).toString('base64url');
  
  // Сохраняем вызов в сессии для последующей проверки
  req.session.challenge = challenge;
  req.session.userId = user.id;
  
  // Генерируем параметры регистрации
  const options = generateRegistrationOptions({
    rpName,
    rpID,
    userID: user.id,
    userName: username,
    userDisplayName: username,
    attestationType: 'none',
    authenticatorSelection: {
      residentKey: 'required',
      userVerification: 'preferred',
    },
    challenge,
  });
  
  res.json(options);
});
Это API-эндпоинт для генерации параметров регистрации. Он принимает имя пользователя, находит или создаёт соответствующую запись в базе данных, генерирует криптографический вызов и возвращает параметры регистрации.

Особое внимание стоит обратить на свойство challenge. Это случайная строка, которая служит для предотвращения атак повторного воспроизведения. Мы сохраняем её в сессии, чтобы потом проверить, что ответ соответствует именно этому вызову. Для верификации ответа от клиента используется другой эндпоинт:

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
app.post('/verify-registration', async (req, res) => {
  const { body } = req;
  
  // Получаем сохраненный вызов
  const challenge = req.session.challenge;
  const userId = req.session.userId;
  
  // Находим пользователя
  const user = await User.findById(userId);
  if (!user) {
    return res.status(400).json({ error: 'User not found' });
  }
  
  try {
    // Верифицируем ответ
    const verification = await verifyRegistrationResponse({
      response: body,
      expectedChallenge: challenge,
      expectedOrigin: origin,
      expectedRPID: rpID,
    });
    
    const { verified, registrationInfo } = verification;
    
    if (verified && registrationInfo) {
      // Сохраняем публичный ключ в базе данных
      const { credentialPublicKey, credentialID } = registrationInfo;
      
      user.credentials.push({
        credentialID: isoUint8Array.toHex(credentialID),
        publicKey: isoUint8Array.toHex(credentialPublicKey),
        counter: registrationInfo.counter,
      });
      
      await user.save();
      
      // Очищаем сессию
      delete req.session.challenge;
      delete req.session.userId;
      
      return res.json({ success: true });
    }
  } catch (error) {
    console.error(error);
    return res.status(400).json({ error: error.message });
  }
  
  return res.status(400).json({ error: 'Verification failed' });
});
Этот эндпоинт принимает ответ от клиента после создания паскея, проверяет его с помощью библиотеки SimpleWebAuthn и, если всё в порядке, сохраняет публичный ключ в базе данных.

Самая распространенная ошибка, которую я встречал при реализации серверной части — неправильное сохранение и использование бинарных данных. В WebAuthn многие данные представлены в виде массивов байтов, которые нужно корректно сериализовать для хранения и десериализовать при использовании. Для аутентификации процесс похож, но есть некоторые отличия:

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
app.post('/generate-authentication-options', async (req, res) => {
  // Генерируем случайный вызов
  const challenge = crypto.randomBytes(32).toString('base64url');
  
  // Сохраняем вызов в сессии
  req.session.challenge = challenge;
  
  // Если у нас есть информация о пользователе (например, из cookie),
  // можно предоставить список разрешенных учетных данных
  let allowCredentials = [];
  if (req.cookies.userId) {
    const user = await User.findById(req.cookies.userId);
    if (user) {
      allowCredentials = user.credentials.map(cred => ({
        id: isoUint8Array.fromHex(cred.credentialID),
        type: 'public-key',
        transports: ['internal', 'hybrid'],
      }));
    }
  }
  
  // Генерируем параметры аутентификации
  const options = generateAuthenticationOptions({
    rpID,
    challenge,
    allowCredentials,
  });
  
  res.json(options);
});
Этот эндпоинт генерирует параметры аутентификации. Если у нас есть информация о том, какой пользователь пытается войти, мы можем ограничить список разрешенных учетных данных только теми, которые принадлежат этому пользователю.
И, наконец, верификация аутентификации:

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
52
53
54
55
56
57
58
59
app.post('/verify-authentication', async (req, res) => {
  const { body } = req;
  
  // Получаем сохраненный вызов
  const challenge = req.session.challenge;
  
  // Извлекаем ID учетных данных
  const credentialID = body.id;
  
  // Находим пользователя по ID учетных данных
  const user = await User.findOne({
    'credentials.credentialID': credentialID
  });
  
  if (!user) {
    return res.status(400).json({ error: 'User not found' });
  }
  
  // Находим соответствующие учетные данные
  const credential = user.credentials.find(
    c => c.credentialID === credentialID
  );
  
  try {
    // Верифицируем ответ
    const verification = await verifyAuthenticationResponse({
      response: body,
      expectedChallenge: challenge,
      expectedOrigin: origin,
      expectedRPID: rpID,
      authenticator: {
        credentialPublicKey: isoUint8Array.fromHex(credential.publicKey),
        credentialID: isoUint8Array.fromHex(credential.credentialID),
        counter: credential.counter,
      },
    });
    
    const { verified, authenticationInfo } = verification;
    
    if (verified) {
      // Обновляем счётчик
      credential.counter = authenticationInfo.counter;
      await user.save();
      
      // Очищаем сессию
      delete req.session.challenge;
      
      // Создаём JWT или другой токен аутентификации
      const token = generateToken(user);
      
      return res.json({ token });
    }
  } catch (error) {
    console.error(error);
    return res.status(400).json({ error: error.message });
  }
  
  return res.status(400).json({ error: 'Verification failed' });
});
Этот эндпоинт проверяет ответ от клиента при аутентификации. Если проверка успешна, генерируется токен аутентификации (JWT или другой формат), который клиент может использовать для последующих запросов.
Отдельно стоит упомянуть о схеме данных для хранения информации о паскеях. В MongoDB она может выглядеть так:

JavaScript
1
2
3
4
5
6
7
8
9
10
const userSchema = new mongoose.Schema({
  username: String,
  credentials: [{
    credentialID: String,
    publicKey: String,
    counter: Number,
    transports: [String],
    createdAt: { type: Date, default: Date.now },
  }],
});
Важно хранить не только публичный ключ, но и счётчик (counter), который используется для защиты от клонирования учетных данных. Каждый раз при успешной аутентификации аутентификатор увеличивает счётчик, и сервер должен проверять, что новое значение больше или равно предыдущему.

Тестирование и отладка



Тестирование функциональности паскеев представляет особую проблему. Как проверить аутентификацию, требующую биометрии, на эмуляторе? Как протестировать все возможные сценарии ошибок? В этой главе я поделюсь проверенными подходами, которые выработал на практике. Начнем с эмуляции биометрии в Android Studio. Многие разработчики не знают, что современные версии эмулятора Android поддерживают имитацию биометрических датчиков. Чтобы настроить это, создайте виртуальное устройство с API уровня 28 или выше, затем включите опцию "Enable fingerprint" при настройке. Когда ваше приложение запросит биометрию, вы увидите специальный диалог в эмуляторе, где можно имитировать успешное или неудачное сканирование.

Kotlin
1
2
3
4
// В файле build.gradle.kts добавьте зависимости для тестирования
testImplementation("androidx.test:core:1.5.0")
testImplementation("androidx.test:runner:1.5.2")
testImplementation("androidx.test.ext:junit:1.1.5")
Один из самых эффективных приемов, который я использую — это мокирование серверных ответов. Для автономной разработки невероятно полезно иметь локальный "фейковый" сервер, который возвращает предсказуемые ответы:

Kotlin
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
class FakeAuthServer {
  fun getRegistrationOptions(): String {
      return """
          {
              "challenge": "randomChallenge",
              "rp": {
                  "name": "Test App",
                  "id": "android.test.app"
              },
              "user": {
                  "id": "test-user-id",
                  "name": "testuser",
                  "displayName": "Test User"
              },
              "pubKeyCredParams": [
                  {
                      "alg": -7,
                      "type": "public-key"
                  }
              ],
              "timeout": 60000,
              "attestation": "none"
          }
      """.trimIndent()
  }
  
  fun verifyRegistration(json: String): Boolean {
      // В тестовой среде всегда возвращаем успех
      return true
  }
  
  // Аналогично для аутентификации
}
Такой подход позволяет тестировать UI и интеграцию с Credential Manager, не беспокоясь о доступности сервера.
Для инструментального тестирования я создаю отдельные классы, которые оборачивают CredentialManager и предоставляют альтернативную реализацию для тестов:

Kotlin
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
interface CredentialManagerWrapper {
  suspend fun createCredential(context: Context, request: CreatePublicKeyCredentialRequest): CreateCredentialResponse
  suspend fun getCredential(context: Context, request: GetCredentialRequest): GetCredentialResponse
}
 
// Реальная реализация
class RealCredentialManagerWrapper(private val credentialManager: CredentialManager) : CredentialManagerWrapper {
  override suspend fun createCredential(context: Context, request: CreatePublicKeyCredentialRequest) = 
      credentialManager.createCredential(context, request)
  
  override suspend fun getCredential(context: Context, request: GetCredentialRequest) = 
      credentialManager.getCredential(context, request)
}
 
// Тестовая реализация
class TestCredentialManagerWrapper : CredentialManagerWrapper {
  var shouldSucceed = true
  var exceptionToThrow: Exception? = null
  
  override suspend fun createCredential(context: Context, request: CreatePublicKeyCredentialRequest): CreateCredentialResponse {
      exceptionToThrow?.let { throw it }
      // Возвращаем мок-ответ
  }
  
  // Аналогично для getCredential
}
Я вспоминаю случай, когда мы не могли понять, почему в одном из тестов постоянно падает аутентификация. Оказывается, мы мокировали ответ сервера, но забыли правильно закодировать challenge в base64url. Такие нюансы очень важны при тестировании криптографических протоколов.
Для обработки редких ошибок я создаю специальные тестовые сценарии:

Kotlin
1
2
3
4
5
6
7
8
9
10
11
@Test
fun whenDeviceHasNoPasskeysSetup_shouldShowRegistrationPrompt() {
  // Настраиваем тестовую среду для имитации отсутствия паскеев
  testCredentialManager.exceptionToThrow = GetCredentialNoCredentialsException()
  
  // Запускаем аутентификацию
  viewModel.authenticateWithPasskey()
  
  // Проверяем, что показан правильный UI
  assertThat(viewModel.authState.value).isInstanceOf(AuthState.RegistrationNeeded::class.java)
}
Для UI-тестирования паскеев с Espresso я использую Idling Resources, чтобы тесты ждали завершения асинхронных операций:

Kotlin
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class CredentialManagerIdlingResource(private val viewModel: AuthViewModel) : IdlingResource {
  private var callback: IdlingResource.ResourceCallback? = null
  
  override fun getName() = "credential_manager_idling_resource"
  
  override fun isIdleNow(): Boolean {
      val isIdle = viewModel.isOperationInProgress.value != true
      if (isIdle) callback?.onTransitionToIdle()
      return isIdle
  }
  
  override fun registerIdleTransitionCallback(callback: IdlingResource.ResourceCallback) {
      this.callback = callback
  }
}
Автоматизировать тестирование паскеев сложно, но возможно. С инструментами вроде UI Automator можно создать тесты, которые имитируют весь процесс аутентификации пользователя.

Перспективы технологии и рекомендации по внедрению в продакшн



После нескольких лет работы с паскеями я абсолютно уверен, что эта технология — не просто очередной хайп, а будущее аутентификации. Когда Google, Apple и Microsoft одновременно поддерживают стандарт, это говорит о многом. Но как грамотно внедрить паскеи в ваше приложение, чтобы не наступить на те же грабли, что и я?

Первое, что нужно понимать — внедрение паскеев лучше планировать поэтапно. Я рекомендую следующую стратегию:

1. Пилотный запуск — добавьте паскеи как опциональную фичу для небольшой группы пользователей (5-10%). Собирайте обратную связь и аналитику использования.
2. Масштабирование — после исправления выявленных проблем, расширьте доступность до 30-50% пользователей.
3. Полный запуск — сделайте паскеи доступными для всех, но сохраните альтернативные методы входа.
4. Приоритизация — сделайте паскеи методом по умолчанию, но сохраните возможность использовать пароли.

Помню забавный случай, когда на одном проекте решили сразу перевести всех пользователей на паскеи без предварительного тестирования. В итоге техподдержка была завалена обращениями, а рейтинг в Play Store упал на целую звезду! Не повторяйте наших ошибок.

Что касается технической стороны, вот несколько рекомендаций для продакшн-среды:

1. Обрабатывайте все исключения — особенно важно предусмотреть все сценарии ошибок: устройство без биометрии, отсутствие блокировки экрана, истекший срок действия учетных данных.
2. Настройте резервные методы — всегда должен быть запасной вариант аутентификации, будь то пароль, одноразовый код или вход через другое устройство.
3. Мониторьте использование — отслеживайте соотношение успешных и неудачных попыток аутентификации, выявляйте проблемные устройства или версии ОС.
4. Регулярно ротируйте серверные ключи — создайте политику обновления ключей для предотвращения долгосрочных атак.

В контексте безопасности стоит учесть следующие моменты:

1. Безопасность challenge — используйте криптографически стойкие генераторы случайных чисел для создания вызовов.
2. Валидация origin — всегда проверяйте, что запрос пришел с ожидаемого источника, включая корректный пакет приложения и сертификат.
3. Защита от атак повторного воспроизведения — не допускайте повторное использование ответов аутентификации, храня использованные вызовы.

Говоря о производительности, вот что я заметил в реальных приложениях: паскеи действительно ускоряют вход пользователей в приложение. По моей статистике, время от запуска приложения до полного входа сократилось в среднем на 60% по сравнению с комбинацией пароль + 2FA. Это значительно улучшает пользовательский опыт, особенно для приложений, которыми пользуются несколько раз в день.

Если сравнивать паскеи с традиционными методами аутентификации, преимущества очевидны:
  • По сравнению с паролями: не нужно запоминать сложные комбинации, нет проблемы повторного использования паролей, защита от фишинга.
  • По сравнению с OAuth: не требуется делиться данными с третьими сторонами, нет зависимости от внешних сервисов.
  • По сравнению с SMS-кодами: быстрее, надежнее, работает без сотовой связи.

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

Чтение паролей в txt с ftp android stuio
Нужен скрипт для подключения и чтение пароля из txt файла с FTP сервера в Android Studio

Как избавиться от ошибки, возникшей в Android Studio (подробности внутри)?
Как решить эту проблему? Rendering Problems: Failed to load the LayoutLib:...

Assets android безопасность
Добрый день! Файлы находящиеся в assets защищены от копировании &quot;ламерами&quot;? Хочу в...

Безопасность OS Android
Вопрос к специалистам: насколько безопасна работа и хранение конфиденциальных данных на платформе...

Создать генератор паролей в котором можно указать длину пароля и количество паролей
Помогите пожалуйста! Задание: Нужно создать генератор паролей в котором можно указать длину пароля...

Генератор паролей. При генерации нескольких паролей почему то генерируется один и тот же
Добрый вечер форумчане. Написал программу для генерации пароля.При генерации одного пароля всё...

Генераторы мастер паролей на биосы (сброс неизвестных паролей биоса)
Народ, кто каким пользуется например вот https://bios-pw.org

Менеджер паролей браузера/Удаление паролей
Встал у меня вопрос о том чтобы распределить сайты и ресурсы по категориям. На некоторые сайты я...

Безопасность приложения: хранение в коде паролей к бд сервера
Хочу из клиента на прямую подключаться к базе. Данные для подключения придется хранить где то в...

Безопасность админки и хранение паролей
По изучал инфу по хранению паролей, но не уверен, что правильно понял суть для своей задачи....

Безопасность Id и паролей
Какие аргументы можно привести против открытого хранения ID и паролей? В смысле все ID хранятся...

Как сломать термодатчик?
Такая проблема, есть комп, на нем используя любые средства показывает температуру процессора...

Надоела реклама? Зарегистрируйтесь и она исчезнет полностью.
Всего комментариев 0
Комментарии
 
Новые блоги и статьи
Нейросеть на алгоритме "эстафета хвоста" как перспектива.
Hrethgir 06.05.2026
На десерт, когда запущу сервер. Статья тут https:/ / habr. com/ ru/ articles/ 1030914/ . Автор я сам, нейросеть только помогает в вопросах которые мне не известны - не знаю людей которые знали-бы. . .
Асинхронный приём данных из COM-порта
Argus19 01.05.2026
Асинхронный приём данных из COM-порта Купил на aliexpress термопринтер QR701. Он оказался странным. Поключил к Arduino Nano. Был очень удивлён. Наотрез отказывается печатать русские буквы. Чтобы. . .
попытка написать игровой сервер на C++
pyirrlicht 29.04.2026
попытка написать игровой сервер на плюсах с открытым бесконечным миром. возможно получится прикрутить интерпретатор питон для кастомизации игровой логики. что есть на текущий момент:. . .
Контроль уникальности выбранного документа-основания при изменении реквизита
Maks 28.04.2026
Алгоритм из решения ниже разработан на примере нетипового документа "ЗаявкаНаРемонтСпецтехники", разработанного в КА2. Задача: уведомлять пользователя, если указанная заявка (документ-основание). . .
Благородство как наказание
Maks 24.04.2026
У хорошего человека отношения с женщинами всегда складываются трудно. А я человек хороший. Заявляю без тени смущения, потому что гордиться тут нечем. От хорошего человека ждут соответствующего. . .
Валидация и контроль данных табличной части документа перед записью
Maks 22.04.2026
Алгоритм из решения ниже реализован на примере нетипового документа, разработанного в КА2. Задача: контроль и валидация данных табличной части документа перед записью с учетом регламента компании. . .
Отчёт о затраченных материалах за определенный период с макетом печатной формы
Maks 21.04.2026
Отчёт из решения ниже размещён в конфигурации КА2. Задача: разработка отчёта по затраченным материалам за определённый период, с возможностью вывода печатной формы отчёта с шапкой и подвалом. В. . .
Отчёт о спецтехнике находящейся в ремонте
Maks 20.04.2026
Отчёт из решения ниже размещен в конфигурации КА2. Задача: отобразить спецтехнику, которая на данный момент находится в ремонте. Есть нетиповой документ "Заявка на ремонт спецтехники" который. . .
КиберФорум - форум программистов, компьютерный форум, программирование
Powered by vBulletin
Copyright ©2000 - 2026, CyberForum.ru