
QR‑коды вместо стандартной формы входа, отправка Magic Link на почту, OAuth — беспарольные способы стали привычными, и это настолько удобно, что не вызывает у обывателя никаких противоречий.
В этой статье я буду отталкиваться от концепции стандартов WebAuthn/CTAP и покажу экспериментальную альтернативу.
Но для понимания контекста давайте условимся, что:
WebAuthn (Web Authentication API) — это браузерный API, через который веб‑приложение запускает регистрацию/аутентификацию (например, через passkeys).
CTAP (Client‑to‑Authenticator Protocol) — протокол, описывающий, как браузер/ОС общается с аутентификатором (ключ безопасности, смартфон, Windows Hello и т. п.).
Далее для упрощения я буду называть эту связку единым набором стандартов FIDO2, так как он их объединяет.
Преимущества FIDO2 понятны, но есть ряд кейсов, которые требуют отдельного внимания:
Зависимость от устройства. Выход из строя аппаратного ключа или внезапные ограничения вендора, которые сегодня становятся уже не новостью, усложняют процесс восстановления доступа.
Навыки менеджмента ключей. Да, существует облачное резервное копирование, экспорт в KeePassXC и ряд других способов в зависимости от технологии, но со стороны обычного пользователя это не совсем упрощает процесс, а местами усложняет его. Сюда относится также перенос ключа на другое устройство.
Концепция «личность - устройство - аккаунт». Если у пользователя несколько аккаунтов (личный/рабочий), то на каждый аккаунт нужен свой ключ.
И, наконец, философская потребность пользователя самостоятельно менеджерить свои данные.
Я решил поэкспериментировать над этим и сфокусировался на следующих вопросах, а что, если:
сделать меньшую связность сайта с аппаратными ключами?
перенести менеджмент аутентификации ближе к web‑сервису, сохраняя допустимый уровень безопасности?
оставить за пользователем и web‑сервисом право гибко распоряжаться этим процессом и при этом не сильно усложнять UX?
Но тогда концепция «личность - устройство - аккаунт» утрачивается? Да, но пользователь и так может создать несколько фейковых аккаунтов, накупив аппаратных ключей или при должных навыках сымитировать ключ. Где-то процесс проще, где-то сложнее, и он все же возможен. И это уже не вопросы аутентификации, а вопросы авторизации — с разграничением прав и подтверждением личности.
Результатом эксперимента стала экосистема беспарольной аутентификации на основе браузерного расширения — SeedKey. Вы можете посмотреть, как это выглядит уже сейчас на демо странице проекта.
Как это работает
Пользователь создает Identity (мастер‑ключ).
Сайт запрашивает у расширения публичный ключ для текущего домена.
Расширение из мастер‑ключа детерминированно выводит пару (public/private) для домена.
Фронтенд отправляет publicKey на бэкенд.
Бэкенд формирует Challenge (специальный объект для подписи) и отдает его клиенту.
Клиент передает challenge в расширение.
Расширение подписывает Challenge и возвращает подпись.
Бэкенд проверяет подпись тем publicKey, который уже знает, и завершает аутентификацию (выдает токены).
Схематично это выглядит следующим образом:

Детали реализации
Давайте разберем компоненты, из которых состоит наша система:
seedkey-browser-extension— само расширение. На него возложена основная часть криптографических операций и хранение Identity.seedkey-client-sdk— клиентский SDK с хелперами и API для общения с расширением и бэкендом.seedkey-server-sdk— серверный SDK (Node.js) с хелперами для формирования/проверки Challenge и контрактами API.seedkey-auth-service— self‑hosted сервис с готовыми REST API эндпоинтами и логикой аутентификации на базеseedkey-server-sdk.seedkey-db-migrations— Liquibase миграции для PostgreSQL, поддерживающие структуру сущностей, необходимых системе.seedkey-auth-service-helm-chart— Helm‑чарт для деплоя миграций иauth-service.
Для комплексного понимания всей системы мы остановимся на каждом из них.
Примечание. Во всех репозиториях помимо базового README есть подробная документация на русском языке в папке
doc/ru.
Браузерное расширение (seedkey-browser-extension)

GitHub: https://github.com/mbessarab/seedkey-browser-extension
Первое, что нужно сделать пользователю, — создать его Identity. Мне нравится интуитивно понятная концепция seed‑фразы (BIP39): её легко записать/запомнить, и именно она ляжет в основу формирования приватного мастер‑ключа.
С помощью алгоритма PBKDF2‑SHA512 выводим приватный ключ и храним в localStorage.
// Ф��нкция формирования приватного мастер ключа.
async function deriveMasterKey(seedPhrase: string): Promise<Uint8Array> {
const encoder = new TextEncoder();
const normalizedSeed = seedPhrase.normalize('NFKD');
const keyMaterial = await crypto.subtle.importKey(
'raw',
encoder.encode(normalizedSeed),
'PBKDF2',
false,
['deriveBits']
);
const masterKeyBits = await crypto.subtle.deriveBits(
{
name: 'PBKDF2',
salt: encoder.encode('salt'),
iterations: 210_000,
hash: 'SHA-512',
},
keyMaterial,
256
);
return new Uint8Array(masterKeyBits);
}
В текущей MVP версии мастер ключ хранится в незашифрованном виде в localStorage и служит для ознакомления с общей концепцией протокола.
Несмотря на то, что сайт не может читать изолированный контекст расширения (Background / Service Worker), на котором происходят все криптооперации, хранить приватный мастер‑ключ «в голом виде» — не очень здорово.
Поэтому в целевой архитектуре шифровать мастер‑ключ нужно некоторым DeviceKey, который можно получить разными способами:
Passkeys / любая реализация FIDO2;
YubiKey / аппаратный ключ;
Windows Hello;
или мастер‑пароль.
Веб сервису не нужно заботиться о том, какая реализация шифрования мастер‑ключа выбрана у пользователя: Identity пользователя и, соответственно, все пары ключей на домены всегда будут согласованными.

Клиентский SDK (seedkey-client-sdk)
GitHub: https://github.com/mbessarab/seedkey-client-sdk
В первую очередь SDK обеспечивает коммуникацию с расширением через ContentScript и отправляет события с различными actions/payload:
type SeedKeyAction =
| 'check_available'
| 'is_initialized'
| 'get_public_key'
| 'sign_challenge'
| 'sign_message';
function sendToExtension(
action: SeedKeyAction,
payload: SeedKeyRequest
) {
const request = {
type: 'SEEDKEY_REQUEST',
action,
payload
};
const event = new CustomEvent('seedkey:v1:request', {
detail: request
});
document.dispatchEvent(event);
}
API расширения версионируется для обратной совместимости с предыдущими версиями SDK — seedkey:v1:request / seedkey:v1:response.
Для гибкости вы можете самостоятельно реализовать любой этап, используя low-level API, но также можете делегировать это SDK, используя high-level API. При правильной настройке бэкенд‑сервиса ваша реализация может заключаться буквально в несколько строк:
import { getSeedKey, saveTokens, SeedKeyError } from '@seedkey/sdk-client';
// init
const sdk = getSeedKey({
backendUrl: 'https://api.seedkey-server'
});
// проверка наличия расширения
const available = await sdk.isAvailable();
// Проверка инициализации расширения (создана Identity)
const initialized = await sdk.isInitialized();
if (!initialized && !available) {
return;
}
// аутентификация, или регистрация, если новый публичный ключ
try {
const result = await sdk.auth();
console.log('Access Token:', result.token.accessToken);
// сохранение токенов в localStorage
saveTokens(result.token, result.user.id);
} catch (error) {
if (error instanceof SeedKeyError) {
console.error('Code:', error.code, 'Message:', error.message);
}
}
Серверный SDK (seedkey-server-sdk)
GitHub: https://github.com/mbessarab/seedkey-server-sdk
Серверная библиотека — это framework‑agnostic реализация протокола. Она обеспечивает Request/Response‑контракт, формирование Challenge, проверку подписи и дает возможность кастомизировать процесс под свою бизнес‑область.
Внутри библиотеки есть адаптеры для persistence слоя, которые вам необходимо имплементировать:
interface UserStore {
findById(id: string): Promise<User | null>;
findByPublicKey(publicKey: string): Promise<User | null>;
create(publicKey: string, metadata?: UserMetadata): Promise<User>;
updateLastLogin(userId: string, publicKey: string): Promise<void>;
publicKeyExists(publicKey: string): Promise<boolean>;
replacePublicKey?(userId: string, newPublicKey: string, metadata?: KeyMetadata): Promise<PublicKeyInfo | null>;
}
interface ChallengeStore {
save(challenge: StoredChallenge): Promise<void>;
findById(id: string): Promise<StoredChallenge | null>;
markAsUsed(id: string): Promise<boolean>;
isNonceUsed(nonce: string): Promise<boolean>;
delete?(id: string): Promise<void>;
}
interface SessionStore {
create(userId: string, publicKeyId: string, expiresInSeconds?: number): Promise<Session>;
findById(id: string): Promise<Session | null>;
invalidate(id: string): Promise<boolean>;
invalidateAllForUser(userId: string): Promise<void>;
isValid(id: string): Promise<boolean>;
}
type TokenGenerator = (
userId: string,
publicKeyId: string,
sessionId: string
) => Promise<TokenPair>;
Заинжектить в сервис AuthService и пользоваться API для всего флоу аутентификации:
const authService = new AuthService({
config,
users: userStore,
challenges: challengeStore,
sessions: sessionStore,
tokenGenerator,
});
authService.createChallenge(request)
authService.register(request)
authService.verify(request)
Расширение подписывает Challenge по алгоритму Ed25519, и backend проверяет подпись тем публичным ключом, который у него уже есть.
Пример подписанного Challenge:
{
"publicKey": "JGDwSln8/pcQoRFhxVi9VX8bPpjCicoCfzzRyhEoLG8=",
"challenge": {
"nonce": "6EzG5ebclmao8IziuboIejy5HP+eFpdDis7BuwoQqRw=",
"timestamp": 1768037110034,
"domain": "seedkey.mbessarab.ru",
"action": "register",
"expiresAt": 1768037410034
},
"signature": "BZ+b4qbPPPMjuqW5IeFVTS4lqJSOPGS/lr3ANQGKZ23OHDoonW74cie+KtJybLzhpUOGl1PaSTvGYGzo0/cFAw==",
"metadata": {
"deviceName": "Firefox on Windows",
"sdkVersion": "0.0.1"
}
}
Self‑Hosted сервис (seedkey-auth-service)
GitHub: https://github.com/mbessarab/seedkey-auth-service
Это сервис, который упаковывает seedkey-server-sdk и полностью реализует серверную часть протокола, предоставляя готовый REST API‑контракт для клиентского SDK.
Не запрещено, но и не рекомендуется ходить в БД экосистемы напрямую. Оставьте это для
auth-serviceи используйте его эндпоинты для получения информации о пользователе.
Liquibase‑миграции для PostgreSQL (seedkey-db-migrations)
GitHub: https://github.com/mbessarab/seedkey-db-migrations
Миграции создают структуру сущностей в PostgreSQL, необходимую протоколу SeedKey. Вам не нужно вручную выдумывать таблицы/связи — вместо этого просто запустите Docker‑контейнер.
Helm‑чарт (seedkey-auth-service-helm-chart)
GitHub: https://github.com/mbessarab/seedkey-auth-service-helm-chart
И, наконец, Helm‑чарт в вашем кластере сам создаст Namespace, Service и прочие необходимые компоненты, выполнит Job с миграциями и развернет Deployment с auth-service.
Безопасность
Как уже упоминалось ранее, одна из задач SeedKey — сохранить допустимый уровень безопасности, поэтому реализация протокола обеспечивает:
защиту от анти-фишинга на основе деривации пары ключей (public/private) для каждого домена, это предотвращает возможность подделывать подпись ключом от другого домена;
использование алгоритма Ed25519 для подписи;
rate limiting от злоупотребления API подписи в расширении;
реализацию классических refresh/access JWT токенов;
проверку TTL, домена, использованного nonce (на стороне сервера).
Заключение
SeedKey — это экспериментальная экосистема, которая не ставит целью заменить существующие стандарты FIDO2, а наоборот — задуматься об их расширении. В частности:
упростить UX вокруг криптографической аутентификации;
дать промежуточный слой управления ключами с возможностью использования FIDO2;
возможно, подготовить пользователя к переходу на нативные решения, такие, как passkeys;
или занять собственную нишу — покажет время.
И, если у вас возникнут какие-либо вопросы по реализации или внедрению протокола в вашу бизнес‑логику, напишите мне по любому из контактов, и мы вместе разберем ваш кейс.
Чтобы быть на связи, подписывайтесь мой тг канал: https://t.me/MBessarab_dev
Также мне важно собрать ваши предложения, идеи и ваше видение такой концепции, поэтому буду рад любым комментариям :)
