Files
bare/docs/decisions/015-password-never-leaves-client.md
mayatnikovandClaude Opus 5 db45978b16 Этап 6: закалка — лимиты ADR-021, аудит модели угроз, сверка документов
Лимиты: все четыре правила ADR-021 — регистрация 5/час на IP, вход 10/10 мин
на IP и ник, сообщения 30/мин, прочие изменяющие 60/мин; 429 с Retry-After;
X-Real-IP читается только с loopback, иначе адрес соединения — иначе заголовок
отменял бы лимит на IP; карты вёдер ограничены поколениями.

Аудит нашёл то, что пропустили пять раундов ревью:
ADR-056: nginx вёл access_log с IP и полными путями вопреки обещанию deploy.md.
Ники и социальный граф ложились в /var/log/nginx рядом с чистым журналом bare.
ADR-058: «выйти на других устройствах» не обрывал уже открытый SSE — отозванная
сессия продолжала получать сообщения.
ADR-059: промежуточный ключ комнаты был невосстановим. Участник, пропустивший
офлайн два rekey подряд, навсегда не расшифровал бы сообщения среднего ключа —
вопреки обещанию storage.md о повторной попытке после получения keyId.
ADR-063: ACK уходил по одному на конверт, а не пачкой. Получатель в оживлённой
комнате выедал общее ведро подтверждениями и упирался в 429 на всех изменяющих
запросах, включая выход из комнаты: 116 отказов за прогон стало нулём.
ADR-055, 057, 060, 061, 062: ключи вёдер и границы, +dirty у bare version,
403 unknown_device не хоронит сообщение, усечение имени в подсказке ввода,
kdf как оракул после повышения цели KDF.

Модель угроз пополнена тем, что действительно видит оператор: push-подписки
лежат в базе открытым текстом, и вместе с VAPID-ключом с той же машины это
произвольное уведомление на экране блокировки.

README приведён к v1.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015DbCjVfTFq4ZFG8juD45YJ
2026-08-23 06:22:52 +03:00

4.1 KiB
Raw Permalink Blame History

ADR-015: Пароль не покидает клиент — два ключа из одного мастера

Уточнён ADR-058: смена пароля с logoutOthers закрывает и потоки событий отозванных сессий; ADR-062: GET /api/kdf не обещает скрывать существование ника.

Контекст

ADR-005 и ADR-006 используют один пароль и для серверной аутентификации (Argon2id), и как материал ключа шифрования блоба. Если клиент отправляет пароль на сервер в открытом виде, оператор, логирующий тела запросов, получает материал ключа — и обещание «оператор не читает сообщения» рушится на первом же входе. Кроме того, ADR-013 требует повышать число итераций KDF без миграции всех аккаунтов разом, а смена пароля числится открытым вопросом.

Решение

  • Пароль никогда не отправляется на сервер. Клиент выводит мастер-ключ: master = PBKDF2-HMAC-SHA256(NFC(пароль), salt = SHA-256("bare-v1:" + nick), iterations, 256 бит). Соль детерминированная — известна до входа без запроса к серверу.
  • Из мастера через HKDF-SHA256 выводятся два независимых ключа: authKey = HKDF(master, info="bare-auth-v1") — 32 байта, уходит на сервер как «пароль»; kek = HKDF(master, info="bare-kek-v1") — AES-GCM-256, шифрует ключевой блоб и сервер его не видит.
  • Сервер хранит argon2id(authKey). Argon2id остаётся (ADR-002, ADR-005): он защищает дамп базы от использования authKey как готового пароля для входа.
  • Перед входом клиент спрашивает GET /api/kdf?nick= и получает число итераций. Для несуществующего ника сервер отвечает текущим целевым значением — ответ не раскрывает существование ника.
  • Повышение итераций и смена пароля — одна и та же операция POST /api/password: клиент, имея пароль в памяти, выводит новый authKey, перешифровывает блоб новым kek и отправляет оба вместе со старым authKey для подтверждения. Сервер заменяет хеш и блоб атомарно. Смена пароля входит в v1.
  • Смена пароля по желанию пользователя завершает остальные сессии (logoutOthers: true); автоматическое повышение итераций — нет.

Следствия

  • Пассивный оператор не получает материал ключа ни при регистрации, ни при входе. Модель угроз становится честной.
  • Соль из ника означает, что перерегистрация под тем же ником с тем же паролем даёт тот же мастер. Ключевая пара при этом новая — старые архивы нечитаемы (ADR-014), мастер это не спасает.
  • PBKDF2 выполняется один раз на вход; на слабом телефоне 1 000 000 итераций — до нескольких секунд. UI показывает «вычисляем ключ».
  • Вопрос «смена пароля в v1 или позже» закрыт: в v1.