Этап 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:
+40
-12
@@ -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", "ник: 2–32 символа, a–z, 0–9, _")
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user