Этап 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
This commit is contained in:
2026-08-23 06:22:52 +03:00
co-authored by Claude Opus 5
parent 0c878477d2
commit db45978b16
45 changed files with 1522 additions and 282 deletions
+40 -12
View File
@@ -31,7 +31,10 @@ func (s *server) config(w http.ResponseWriter, r *http.Request) {
//
// Значение лежит открытым полем iter в ключевом блобе: другого места
// у него нет (docs/crypto.md). Неизвестный ник получает целевое значение
// тем же статусом 200 — ответ не раскрывает, существует ли ник (ADR-015).
// тем же статусом 200. Скрытием существования ника этот ответ
// не занимается: после повышения цели у аккаунта, который с тех пор
// не входил, iter свой, и по числу его видно (ADR-062). Существование
// ника публично и так (ADR-019).
func (s *server) kdf(w http.ResponseWriter, r *http.Request) {
iterations := config.KDFIterations
if nick := r.URL.Query().Get("nick"); validNick(nick) {
@@ -66,16 +69,9 @@ func (s *server) register(w http.ResponseWriter, r *http.Request) {
if !decode(w, r, &in) {
return
}
if code := s.cfg.InviteCode; code != "" {
if in.Invite == "" {
Error(w, http.StatusForbidden, "invite_required", "нужен инвайт-код")
return
}
if subtle.ConstantTimeCompare([]byte(code), []byte(in.Invite)) != 1 {
Error(w, http.StatusForbidden, "invalid_invite", "инвайт-код не подходит")
return
}
}
// Форма — раньше инвайт-кода: он даёт право регистрироваться, а права
// идут после формы (ADR-043). Занятость ника этим не выдаётся: nick_taken
// живёт дальше по тексту, за инвайтом.
if !validNick(in.Nick) {
Error(w, http.StatusBadRequest, "invalid_nick", "ник: 232 символа, az, 09, _")
return
@@ -94,6 +90,22 @@ func (s *server) register(w http.ResponseWriter, r *http.Request) {
Invalid(w, "blob", err.Error())
return
}
// Лимит — 5 в час на IP (ADR-021) — стоит раньше проверки инвайт-кода:
// иначе код подбирался бы запросами без счёта.
if wait, ok := s.regs.take(clientIP(r), time.Now()); !ok {
s.rateLimited(w, wait)
return
}
if code := s.cfg.InviteCode; code != "" {
if in.Invite == "" {
Error(w, http.StatusForbidden, "invite_required", "нужен инвайт-код")
return
}
if subtle.ConstantTimeCompare([]byte(code), []byte(in.Invite)) != 1 {
Error(w, http.StatusForbidden, "invalid_invite", "инвайт-код не подходит")
return
}
}
cred, err := auth.Hash(key)
if err != nil {
@@ -139,6 +151,14 @@ func (s *server) login(w http.ResponseWriter, r *http.Request) {
invalidCredentials(w)
return
}
// Лимит — 10 за 10 минут на пару IP+ник (ADR-021) — стоит раньше
// хранилища и argon2: перебор не должен заказывать серверу работу.
// Ключ ведра собирается из адреса и ника через байт, которого нет
// ни в том ни в другом.
if wait, ok := s.logins.take(clientIP(r)+"\x00"+in.Nick, time.Now()); !ok {
s.rateLimited(w, wait)
return
}
u, err := s.st.User(r.Context(), in.Nick)
if errors.Is(err, store.ErrNotFound) {
// Считаем впустую: вход с несуществующим ником не должен
@@ -234,10 +254,18 @@ func (s *server) password(w http.ResponseWriter, r *http.Request) {
return
}
sess, _ := auth.From(r)
if err := s.st.SetPassword(r.Context(), u.Nick, cred, in.Blob, in.LogoutOthers, sess.TokenHash); err != nil {
revoked, err := s.st.SetPassword(r.Context(), u.Nick, cred, in.Blob, in.LogoutOthers, sess.TokenHash)
if err != nil {
s.internal(w, r, err)
return
}
// Сессия проверяется при подключении к потоку, а не в его цикле,
// поэтому отозванная продолжала бы получать конверты до обрыва
// соединения. Отзыв доступа закрывает поток сам — тем же способом,
// что и удаление устройства (ADR-058).
for _, device := range revoked {
s.hub.Close(device)
}
noContent(w)
}