Как я строил zero-knowledge менеджер паролей: три архитектуры, модель угроз и восемь багов

Я разрабатываю менеджер паролей "Сэйфком" для личного и командного использования.
Почти все серьёзные проблемы, которые я находил, были не в криптографии, а в коде вокруг неё: авторизации, сериализации ключей, rate limit, SMTP, WebAuthn и обычной бизнес-логике.
Ограничения модели безопасности и текущее состояние сервиса описаны отдельно, на странице безопасности. Формального внешнего аудита на момент публикации не было.
Откуда вообще взялся проект
Годами у меня повторялся один и тот же сценарий: в папке очередного проекта лежал файл info.txt с паролями и информацией о проекте. Доступ к базе, ключ от хостинга, API-токен, пароль от админки заказчика — всё вперемешку, зато по папкам проектов и всегда под рукой.
Через известное время, когда требовалось поделиться доступом, начиналась археология: найти актуальную версию, не перепутать её со старым паролем, отправить нужному человеку и не оставить секрет навсегда в чате.
Работа с веб-разработкой и безопасностью в какой-то момент сделала эту ситуацию особенно неловкой. К открытому текстовому файлу добавлялось расползание секретов по SSH-конфигам, .env, IDE, браузеру и локальным файлам. После смены пароля приходилось вспоминать, где ещё остались старые значения.
Так появилась идея сделать собственный менеджер.
Я постоянно живу с безопасностью, поэтому такие вопросы у меня возникают автоматически:
может ли сотрудник самого сервиса прочитать содержимое моего сейфа;
какие данные сервер видит во время обычной работы;
что произойдёт с доступом при передаче сейфа другому человеку;
что произойдёт, если украдут базу вместе с резервными копиями;
какие метаданные всё равно остаются видимыми;
и насколько сильно я вообще завишу от самого сервиса.
Для менеджера паролей последний пункт особенно неприятен. Если в нём хранятся ключи от инфраструктуры компании, зависимость от сервиса становится уже операционным риском.
Почему я решил сделать свой менеджер
Готовых менеджеров достаточно, поэтому вопрос «зачем писать свой?» вполне закономерен. Хотелось проверить себя на такой архитектуре и попробовать довести её до полноценного продукта. Ну и заодно понять, получится ли из этого бизнес. Но главным техническим вопросом для меня было другое: можно ли сделать менеджер так, чтобы сервер не получал ключи, необходимые для расшифровки уже сохранённых данных.
В этой статье под zero-knowledge я имею в виду следующее: сервер не получает мастер-пароль, plaintext записей и ключи, которыми можно расшифровать уже сохранённые данные.
При этом сервер видит email, IP, User-Agent и служебные данные командного доступа: участников сейфов, роли, идентификаторы сейфов и папок. Названия сейфов и папок хранятся зашифрованными.
А если сервер сможет подменить JavaScript, который получает браузер, шифрование базы уже не поможет. Такой код может перехватить мастер-пароль, ключи или расшифрованные записи прямо на клиенте, поэтому client-side encryption защищает данные от компрометации серверного хранилища, но не от полностью скомпрометированного клиента.
А персональные данные?
Отдельный вопрос про персональные данные. Email, сведения о сессиях, участниках команд и требования к их обработке здесь рассматриваются отдельно, это уже не криптографическая задача.
Данные сервиса хранятся на серверах в России. Подробности об обработке персональных данных вынесены на страницу безопасности.
Сначала модель угроз, потом код
Перед тем как писать код, я выписал, от чего вообще должна защищать система.
Утечка базы. Атакующий получает дамп базы и резервные копии. В них остаются аккаунты и метаданные, а содержимое записи (название, логин, пароль, URL и заметки) хранится в зашифрованном виде. Без ключа расшифровки украденная база не должна превращаться в открытые пароли.
1. Украдена база. Дамп базы не содержит готовых plaintext-записей: без PasswordKEK нельзя снять обёртку AccountKey и добраться до VaultKey.
Но в базе хранится auth_key_verifier, который используется как bearer credential для API. Поэтому украденная база может дать атакующему API-доступ к аккаунту без знания мастер-пароля. При этом auth_key_verifier не позволяет получить ключи для расшифровки существующих записей: auth_key_verifier и PasswordKEK получаются из мастер-пароля независимо друг от друга через Argon2id.
2. Подменён клиентский JavaScript. Здесь защита заканчивается. Вредоносный клиент может перехватить мастер-пароль, AccountKey, VaultKey или уже расшифрованные записи прямо в браузере.
3. Сервер подменяет данные. Например, сервер может подменить публичный X25519-ключ участника. Клиент обнаружит такую замену после первого получения благодаря подписи и TOFU, но сама первая выдача ключа остаётся отдельной проблемой. Подробнее об этом ниже.
Заражённое устройство. Если злоумышленник контролирует браузер или операционную систему после разблокировки, он может читать данные там же, где их читает пользователь. Шифрование базы от этого сценария не защищает.
Фишинг. Архитектура хранения тоже не делает фишинг невозможным. Passkey с проверкой домена снижает риск для конкретного сценария входа, но не заменяет защиту устройства и внимательность пользователя.
Откат данных. Есть и сценарий с rollback. Сервер может попытаться вернуть старую версию записи из entry_history. version_seq защищает от простого rollback: если сервер возвращает старый ciphertext, но оставляет текущую версию, проверка AAD не пройдёт. Но version_seq не устраняет сценарий согласованной подмены сервером нескольких связанных значений. Если сервер меняет ciphertext вместе с идентификатором и версией, одной проверки GCM недостаточно.
Только стандартные примитивы криптографии
Криптографию я не изобретал. В проекте используются стандартные примитивы и готовые реализации:
Argon2id через
hash-wasm;AES-256-GCM через Web Crypto API;
X25519 через
@noble/curves;HKDF-SHA-256;
HMAC-SHA-256;
WebAuthn PRF.
Цепочка ключей Safekom, что где живёт и что видит сервер:
Сущность | Где живёт | Чем защищена | Что видит сервер |
|---|---|---|---|
Мастер-пароль | только у клиентана сервер не уходит | не хранится нигде | нет, ни разу |
PasswordKEK | память клиента, временно | выводится из мастер-пароля через Argon2id | нет |
AccountKey | сервер, тольков обёрнутом виде | обёртка от PasswordKEK, PRF-производного KEK или recovery-KEK | только сам ciphertext-конверт |
VaultKey | сервер, тольков обёрнутом виде | X25519-конверт для каждого получателя, включая владельца сейфа, единый путь без отдельной ветки | только сам envelope-конверт |
Содержимое записи | plaintext у клиента,на сервере шифротекст | AES-256-GCM, ключ VaultKey, AAD | только ciphertext |
X25519-приватный ключ участника | у клиента | не покидает браузер | нет |
Поисковый токен | сервер, как индексируемое значение | HMAC-SHA-256,не сам plaintext | да, сам токен |
X25519-публичный ключ получателя | сервер, раздаётсяпо запросу | TOFU и подпись identity-ключом; первый полученный ключ нужно сверить по независимому каналу. | да, и раздаёт его сам |
В системе есть три основные формы данных: plaintext — открытые данные на клиенте; wrapped — зашифрованная обёртка ключа; ciphertext — зашифрованные данные. Поэтому фраза «ключ хранится на сервере» всегда означает wrapped-форму: сам секретный ключ сервер получать не должен.
Argon2id используется для получения ключей из мастер-пароля через hash-wasm. Сейчас параметры такие: 64 МБ памяти, 3 прохода, parallelism 4 и соль 16 байт. Один из рекомендованных RFC 9106 профилей использует 2 ГБ памяти, но для браузерного клиента я его не выбрал: на обычных машинах он оказался слишком тяжёлым. Поэтому остановился на более лёгкой конфигурации. Скорость Argon2id заметно зависит от устройства и браузера, поэтому в Сэйфком есть отдельная страница, где можно замерить её работу непосредственно на своём компьютере.
AES-256-GCM используется для шифрования ключей и содержимого записей через Web Crypto API. AES-GCM обеспечивает конфиденциальность и целостность ciphertext и AAD. Для каждой операции генерируется новый случайный 96-битный IV. Он не секрет и хранится рядом с ciphertext. Важно только, чтобы он не повторялся для одного ключа.
Использование AAD
Для записей используется Additional Authenticated Data (AAD). Сейчас в него входит vault_id:client_uuid:version_seq. Раньше использовался формат vault_id:client_uuid, а до добавления client_uuid был только vault_id.
client_uuid привязывает ciphertext к конкретной записи, а version_seq ещё и связывает его с конкретной версией этой записи. Поэтому старый, но корректно зашифрованный ciphertext из entry_history нельзя просто вернуть как текущую версию: при проверке AAD версия не совпадёт.
AAD защищает привязку ciphertext к vault_id, client_uuid и version_seq, но не защищает состояние от согласованной подмены нескольких значений. Если сервер меняет ciphertext вместе с идентификатором и версией, одной проверки GCM недостаточно.
X25519
Сначала командный шаринг у меня работал через P-256. Потом вокруг ключей стало появляться слишком много собственного кода, и один из багов как раз оказался в преобразовании ключевого материала. Поэтому при следующей переделке я перешёл на X25519.
Сейчас для каждого участника из X25519 shared secret через HKDF-SHA-256 получается отдельный ключ, которым заворачивается VaultKey. Сервер хранит эти обёртки, сам VaultKey ему не нужен.
В первой реализации я делал SHA-256(shared_secret). Это работало, но мне не нравилось, что назначение производного ключа никак явно не разделено. В X25519-схеме я заменил это на HKDF и использую info для разделения контекстов.
Откуда вообще брать public key участника? Просто получить его от сервера недостаточно: сервер может выдать клиенту другой ключ. Поэтому X25519 public key подписывается identity-ключом аккаунта, а при первом получении клиент сохраняет его через TOFU. Последующее изменение ключа обнаруживается.
HMAC-SHA-256 и поиск
Поиск по зашифрованным данным сделан через HMAC-токены. Сервер получает токен и может найти соответствующие записи, но исходного текста запроса не видит.
У этого решения есть очевидная цена: токен детерминированный. Если одно слово встречается несколько раз, сервер получает один и тот же токен и может видеть частоту повторений. То же самое касается повторного поиска одного и того же слова.
Почему hash-wasm, а не libsodium
Обе библиотеки реализуют Argon2id, независимого аудита я не проводил. Поэтому выбор между ними для меня был инженерным: проекту нужна только Argon2id, а AES-GCM уже есть в Web Crypto API, X25519 в @noble/curves.
Argon2id загружается отдельно и лениво через hash-wasm. Это позволяет не тащить в клиент библиотеку ради одной функции.
Почему X25519, а не P-256
На старте командный шаринг работал через ECDH P-256. Вокруг него было много собственного кода преобразования ключевого материала, и именно там я в итоге нашёл баг, описанный ниже.
P-256 сама по себе здесь не была проблемой, ошибка находилась в моём коде преобразования ключей. Поэтому переход на X25519 был скорее способом уменьшить количество собственного кода вокруг ключей.
X25519 уже поддерживается Web Crypto API, но для предсказуемого поведения в браузерах в проекте используется @noble/curves, а AES-GCM я предпочёл оставить в Web Crypto API.
Как проходит работа с записью

Упрощённо цепочка выглядит так:
Мастер-пароль → Argon2id → PasswordKEK → AccountKey → VaultKey → AES-GCM → ciphertext.
Мастер-пароль и ключи расшифровки в открытом виде серверу не отправляются.
Для проверки входа используется отдельный auth_key_verifier, полученный через отдельную операцию Argon2id со своей солью, не тот же PasswordKEK. При логине сервер напрямую сравнивает присланное значение с хранимым через hash_equals, без дополнительного хеширования пароля на своей стороне.
auth_key_verifier не является ключом расшифровки. Даже имея его на руках, нельзя получить PasswordKEK или открыть account_key_wrapped_by_password: оба значения зависят от мастер-пароля и выводятся отдельными операциями Argon2id.
Зато для API это полноценный ключ доступа (bearer-токен). Если он попал в руки атакующего вместе с базой, тот может создать сессию и работать с API от имени пользователя. Сами записи останутся зашифрованными.
Recovery code
Кроме мастер-пароля и passkey есть третий способ разблокировки AccountKey: recovery code - 32 байта криптографически случайных данных (256 бит), которые генерируются локально в браузере и показываются пользователю один раз.
Сам код сервер никогда не получает, из него локально через Argon2id получается ключ для снятия обёртки AccountKey.
Для поиска нужного аккаунта сервер получает не сам recovery code, а HMAC-SHA-256 от него с отдельным серверным секретом. Это позволяет найти аккаунт, не храня код в открытом виде.
Название колонки recovery_code_hash осталось исторически: криптографически это не password hash и не verifier, а идентификатор для поиска.
Passkey: аутентификация это ещё не расшифровка
С WebAuthn оказалось ещё интереснее. Passkey может успешно доказать серверу, что credential принадлежит пользователю, но из этого не следует, что браузер получил ключ, которым можно расшифровать сейф.
Для этого используется WebAuthn PRF: он даёт 32 байта вывода, криптографически связанного и с конкретным credential, и со входом, который ему передали (prf_salt, публичный параметр, который не нужно считать секретом).
При одинаковом входе разные credential всё равно дают разные PRF-выводы, поэтому одна обёртка AccountKey не подходит автоматически всем passkey. Она хранится отдельно для каждого credential.
PRF-расширение — это опциональная возможность конкретного authenticator, не гарантированное свойство любого passkey. Поэтому при регистрации клиент отдельным запросом проверяет, действительно ли authenticator отдаёт PRF-вывод, прежде чем создать обёртку.
Если PRF недоступен, пользователь сразу получает понятную ошибку вместо непонятного сбоя позже при входе.
passkey #1 → PRF → KEK #1 → wrapped AccountKey #1
passkey #2 → PRF → KEK #2 → wrapped AccountKey #2
recovery → KDF → KEK #3 → wrapped AccountKey #3

Здесь я впервые упёрся в различие между «пользователь вошёл» и «пользователь может расшифровать сейф».
В обычном CRUD после authenticated(user) вопрос обычно закрыт. Здесь нет. У клиента может быть валидная сессия, но не быть ключа от конкретного vault.
Поэтому в коде пришлось разделить эти два состояния: authenticated(user) и can_decrypt(vault).
SSDLC: как я проверяю security инварианты
Команды у меня нет, поэтому часть процесса пришлось выстроить самому. Для security-багов правило простое: нашёл проблему — добавил регрессионный тест. Если теста нет, я не считаю исправление законченным.
Модель угроз тоже пришлось перестать воспринимать как документ «написал и забыл». После гостевых ссылок появились новые сценарии доступа, после passkey новые ключевые зависимости, после командного доступа вопрос проверки публичных ключей.
Отдельно проверяю, что секреты действительно не уходят с клиента. Для этого есть e2e-тест: он проходит регистрацию, создание сейфа и записи, раскрытие пароля, переименование и ротацию ключа, одновременно перехватывая исходящие запросы.
Тест ищет не только мастер-пароль и введённые пользователем значения, но и сырой AccountKey и VaultKey. Я специально два раза ломал код, добавляя такую утечку, чтобы убедиться, что тест действительно её ловит.
В CI сейчас запускаются backend и frontend-тесты, Playwright e2e, визуальная регрессия, ESLint security rules, Semgrep, health API и проверки миграций. Всего больше тысячи автоматических тестов.
Когда одна и та же проверка размазана по восьми контроллерам, рано или поздно её забудут в девятом.
Иногда нельзя просто добавить ещё один if. Если модель данных не выражает нужное ограничение, проверка в контроллере проблему только прячет.
Три версии архитектуры
Проект начинался как прототип, где я проверял саму идею и базовую криптографическую схему. Когда стало понятно, что подход работает, я зафиксировал первую полноценную архитектуру под названием Safekom, в русскоязычном интерфейсе «Сэйфком». С этого момента я и начинаю считать версии архитектуры.
Я не переписывал проект просто ради архитектурной чистоты. Ломать рабочий MVP ради красоты кода было бы плохим решением. Переход на следующую версию происходил только тогда, когда появлялось требование, которое текущая модель уже не могла нормально выразить.
Версия 1. Личный сейф, записи, мастер-пароль, шифрование. Один пользователь мог иметь несколько сейфов, но команд не было вообще, и модель доступа была под стать: простая, потому что задача была простая.
Версия 2. Простая схема упёрлась в организации, команды, роли и доступ к отдельным сейфам. Старая модель не могла выразить несколько независимых способов получить доступ к одному зашифрованному ключу, пришлось переработать почти всё: личные пространства, организации, команды, отношения между участниками и сейфами, импорт, серверную авторизацию, жизненный цикл VaultKey.
Версия 3, текущая, добавила passkey, личные пространства, общие сейфы, гостевые ссылки, Emergency Access и отдельные криптографические обёртки для каждого способа разблокировки.

Командный доступ: самое неприятное ограничение

С командным доступом появилась отдельная криптографическая задача: для каждого участника VaultKey отдельно заворачивается через X25519 и HKDF. Сервер хранит эти обёртки, но самого VaultKey не видит.
Отзыв доступа
При обычном отзыве удаляется соответствующая wrapped VaultKey и API перестаёт выдавать участнику данные. Но если участник уже получил VaultKey, сервер не может забрать его обратно.
«Отозвать» здесь означает сразу три разных вещи.
Первое: API revoke: участник больше не получает данные через сервис.
Второе: криптографический revoke: после ротации VaultKey участник больше не может расшифровать новые данные.
Третье: уже скачанные данные. Их отозвать нельзя: если человек получил VaultKey и сохранил данные, сервер уже не может забрать их обратно.
Для криптографического отзыва доступа в проекте есть отдельная операция ротации: клиент создаёт новый VaultKey, перешифровывает записи, включая корзину, создаёт новые wrapped keys для текущих участников, сервер применяет всё одной атомарной транзакцией.
Но ротация не запускается автоматически при revoke, это сознательный компромисс: связать их означало бы, что каждый отзыв доступа требует перешифровать весь сейф.
Revoke — это отзыв API доступа, rotation — это отдельная операция владельца. Если нужно криптографически исключить участника из будущего доступа, её надо выполнить явно.
Восемь багов, которые я нашёл в собственном проекте
Ниже восемь конкретных дефектов.
1. SMTP инъекция через название организации

На первый взгляд тут ничего подозрительного: название организации попадало в тему письма приглашения, обычное поле формы.
Но старый raw SMTP клиент позволял через CR/LF завершить Subject и добавить собственный заголовок, например Bcc.
Причина: поле проверялось только на непустоту, а очистку доверили форме. Но SMTP - это граница системы: данные нужно проверять непосредственно перед сборкой заголовков.
Исправление: значения с CR/LF отклоняются до сборки SMTP-заголовков, адрес получателя отдельно проходит валидацию. Заголовки формируются через SMTP-библиотеку, а не конкатенацией пользовательских строк.
2. Пароль гостевой ссылки можно было перебирать без лимита

У гостевой ссылки был случайный токен и, при необходимости, пароль. С токеном всё нормально: подобрать его перебором практически нереально.
А вот с паролем получилась другая история.
Для обычного логина у нас уже был rate limit. После нескольких неправильных попыток новые запросы с того же источника начинали блокироваться. Я сначала считал, что с гостевыми ссылками будет так же.
Но нет.
Гостевая ссылка работает без авторизации, и запрос с паролем вообще не попадал в тот же механизм ограничения. В итоге, если у тебя уже есть ссылка, можно было просто отправлять один запрос за другим с разными паролями.
пароль 1 → ошибка
пароль 2 → ошибка
пароль 3 → ошибка
...
правильный пароль → доступ к даннымТо есть случайный токен защищал саму ссылку, но если ссылка каким-то образом утекла, пароль можно было спокойно перебирать с сервера без ограничения количества попыток.
Исправил это отдельно для гостевых ссылок: добавил rate limit по паре guest link + IP.
Теперь с одного IP нельзя бесконечно долбить одну и ту же гостевую ссылку.
От распределённого перебора это, конечно, не спасает — запросы можно раскидать по разным IP. Но обычный перебор с одной машины теперь упирается в лимит.
3. Emergency Access существовал в коде, но фактически не работал

Сначала я вообще искал проблему не там.
В Emergency Access отзыв гранта владельцем возвращал 404, автоматическое одобрение после истечения периода ожидания тоже не происходило, и причин оказалось сразу несколько.
В одном месте проверялась неправильная сторона отношения. В другом запрос на авто-одобрение был написан и протестирован против SQLite, которую я использую локально и в тестах, а синтаксис не совпал. А сам метод автоматического одобрения вообще не был подключён к планировщику.
Три разных дефекта давали один внешний симптом: ничего не происходило.
Исправление: поправлены условия доступа, SQL адаптирован под MySQL, добавлена cron-задача, добавлены тесты для истёкшего и неистёкшего периода.
4. PIN изначально хранился как быстрый SHA-256

Изначально PIN защищался обычным SHA-256. Для PIN это плохой вариант: пространство из 4–6 цифр перебирается очень быстро.
Сейчас используется PBKDF2-HMAC-SHA-256 с 600 000 итерациями. Первые 32 байта результата используются для verifier, вторые 32 как ключ AES-GCM для AccountKey. Я выбрал PBKDF2 вместо Argon2id, чтобы локальная разблокировка не была такой же тяжёлой, как ввод мастер-пароля.
PIN остаётся слабым секретом: KDF увеличивает стоимость перебора, но не добавляет энтропии самому PIN.
Verifier и соль хранятся в localStorage, зашифрованный AccountKey в sessionStorage и очищается после закрытия вкладки. Это не является защитой от XSS: скомпрометированный JavaScript всё равно может перехватить PIN или уже расшифрованный AccountKey. Счётчик попыток также находится на клиенте и не считается надёжной защитой от атакующего, который контролирует приложение.
Поэтому модель здесь простая: PIN защищает от случайного доступа к уже открытому устройству, но не от компрометации самого клиента.
5. Ответ на запрос соли выдавал существующий email

Я сравнил ответы на два запроса, для существующего и несуществующего email, и ответы отличались.
Для несуществующего endpoint солей возвращал пустой ответ, для существующего адреса endpoint возвращал реальные значения.
Получался простой oracle существования аккаунта: по ответу можно было перечислять зарегистрированные адреса.
Причина: различался сам ответ API.
Исправление: для неизвестного адреса сервер тоже возвращает значение, детерминированно вычисленное через HMAC с секретом сервера. Это не обычная случайная соль, корректнее считать это keyed lookup value.
Главное свойство: ответ одинаков по форме независимо от существования аккаунта.
6. Собственный конвертер P-256 терял ведущий нулевой байт

Этот баг занял у меня больше всего времени.
OpenSSL отдавал координаты в числовом представлении, а мой код представлял её как целое число переменной длины, и при обратной сериализации я не всегда восстанавливал фиксированные 32 байта.
Если первый байт был 00, он исчезал, и часть COSE-ключей содержала координату длиной 31 байт вместо 32.
Браузер при регистрации не жаловался, а вход ломался только для некоторых ключей.
Сначала грешил на браузер пользователя.
Исправление: убрал PEM-конвертацию через OpenSSL, теперь исходный COSE_Key хранится в том формате, который отдаёт браузер. Добавлен тест на фиксированную длину координат.
Этот случай заставил меня добавить отдельные тесты именно на сериализацию ключевого материала, а не только на сам протокол.
7. Passkey мог аутентифицировать пользователя, но не расшифровать AccountKey
Сценарий выглядел странно: passkey успешно проходил WebAuthn-аутентификацию, но AccountKey не удавалось расшифровать.
Причина: в ранней версии обёртка AccountKey была общей для пользователя, а PRF-вывод зависит от конкретного credential и входных данных PRF.
Даже при одинаковом публичном prf_salt ключи, полученные от разных credential, разные, поэтому одна обёртка не могла обслуживать несколько passkey.
Исправление: обёртка хранится отдельно для каждого credential в webauthn_credentials, сервер отдаёт только ту, что соответствует конкретному credential_id.
При регистрации второго passkey клиент сразу создаёт новую wrapped-копию AccountKey, при удалении credential удаляется и обёртка.
Данный баг заставил меня окончательно разделить authentication и decryption authorization.
8. Защита от enumeration снова сломалась, на этот раз через регистрацию

Здесь обнаружилась ещё одна проблема с определением существующих аккаунтов по ответу сервера.
Первую проблему с enumeration, баг №5, я исправил слишком узко: при регистрации клиент генерировал собственную случайную соль, которая отличалась от детерминированного значения, которое сервер отдавал для этого email раньше.
Значит, запрос /api/auth/salts до и после регистрации возвращал разные значения, и наличие аккаунта можно было определить не по содержимому ответа, а по изменению значения между двумя запросами.
Проблема всё равно оставалась, просто переместилась.
Я исправил конкретный случай, но не зафиксировал общее требование для всех способов входа.
Теперь правило звучит иначе: ни один auth flow не должен предоставлять клиенту надёжный oracle существования аккаунта через содержимое, структуру, статус или наблюдаемое поведение ответа.
Атаки timing side channels требуют отдельного анализа.
Исправление: сервер больше не принимает криптографически значимую соль от клиента при регистрации, а сам вычисляет тот же детерминированный KDF input через отдельный HMAC-ключ.
Значение для одного и того же email остаётся одинаковым до и после регистрации.
Заодно HMAC-ключ для этих значений отделён от JWT_SECRET в SALT_HMAC_SECRET: разные назначения и жизненный цикл.
Сама метка версионирована (kek_v1 вместо kek), чтобы можно было сменить формат без переиспользования старой соли.
Что в итоге защищено
Сводная таблица: какие security-свойства модель Safekom обеспечивает, а какие нет
Свойство | Статус |
|---|---|
Конфиденциальность записей при краже базы данных целиком | Да |
Целостность шифротекста (AES-GCM tag) | Да |
Привязка шифротекста записи к конкретной записи и версии через AAD(не защищает от согласованной подмены ciphertext и связанных значений) | Частично |
Защита от отката записи к старой версии (replay / rollback) | Частично |
TOFU обнаруживает последующую замену; подпись identity-ключом проверяет происхождение ключа. Первый полученный ключ нужно сверить по независимому каналу. | Частично |
Forward secrecy | Не заявляется |
Защита секретов при успешной XSS в origin | Нет |
Защита от компрометации уже разблокированного устройства | Нет |
Отзыв уже скачанного участником ключа (уже сохранённой копии) | Нет |
Стойкость к офлайн-перебору мастер-пароля(зависит от качества самого пароля и параметров Argon2id) | Зависит |
PIN как самостоятельный криптографический секрет(пространство PIN небольшое, KDF не превращает его в эквивалент мастер-пароля) | Нет |
Anti-enumeration на всём auth-flow (salts / login / register / recovery / passkey) | Частично |
Поэтому формулировка «защищён от компрометации сервера» для Safekom слишком широкая. От кражи базы записи защищены. От злонамеренного сервера, который контролирует выдаваемый клиенту код и данные, нет.
Что нового в исследованиях password managers
Недавно вышла работа «Zero Knowledge (About) Encryption: A Comparative Security Analysis of Three Cloud based Password Managers», подготовленную исследователями ETH Zurich и USI для USENIX Security '26.
Авторы исследовали три популярных облачных менеджера паролей, Bitwarden, LastPass и Dashlane, в модели, где сервер считается полностью злонамеренным.
В результате исследователи описали 25 атак: 12 против Bitwarden, 7 против LastPass и 6 против Dashlane.
Атаки различались по последствиям: от нарушения целостности отдельных хранилищ до компрометации всех хранилищ организации.
Проблемы возникали на стыках: API и прав доступа, данных и SMTP, ключей и сериализации, аутентификации и расшифровки, серверной логики и anti-enumeration.
Архитектура и модели угроз Safekom и рассмотренных в исследовании продуктов различаются, поэтому я не ставлю целью прямое сравнение.
Официальная страница исследования: USENIX Security '26.
Продукт: safekom.ru
Модель угроз и список данных, видимых серверу: страница безопасности
Сообщить об уязвимости: security@safekom.ru
Формальной bug bounty программы пока нет, но она в планах.
Если вы тоже строите продукт с client-side encryption или сталкивались с похожими проблемами, буду рад обсудить их в комментариях.
P.S. Читателям этой статьи могу дать PRO в Сэйфком навсегда. Достаточно написать мне и указать, что вы пришли с Хабра.
Сэйфком во многом появился из желания не просто писать код, а довести собственную архитектурную идею до работающего продукта. Сейчас я открыт к предложениям о работе и интересным проектам в области backend-разработки, архитектуры, информационной безопасности и client-side encryption.
Если вам близок такой стек и подход к разработке, буду рад пообщаться.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.