Этап 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
+47 -26
View File
@@ -36,8 +36,12 @@ type server struct {
st *store.Store
hub *hub.Hub
push *push.Sender
msgs *buckets
logw io.Writer
// Лимиты ADR-021: у каждого правила своё ведро и свой ключ.
regs *buckets // регистрация — по адресу
logins *buckets // вход — по паре адрес+ник
msgs *buckets // сообщения — по нику
writes *buckets // остальные изменяющие запросы — по нику
logw io.Writer
}
// Handler — обработчик всех маршрутов, живые SSE-потоки и очередь пушей
@@ -48,11 +52,18 @@ type Handler struct {
push *push.Sender
}
// Close закрывает открытые потоки событий и останавливает отправку
// пушей. Без него остановка сервера ждала бы, пока клиенты уйдут сами:
// у потока нет конца (ADR-004).
func (h *Handler) Close() {
// CloseStreams закрывает открытые потоки событий. Без этого остановка
// сервера ждала бы, пока клиенты уйдут сами: у потока нет конца (ADR-004).
// Ничего не ждёт сама и потому годится в http.Server.RegisterOnShutdown.
func (h *Handler) CloseStreams() {
h.hub.CloseAll()
}
// Close останавливает всё, что живёт за обработчиком: потоки событий
// и отправку пушей, — и дожидается начатых отправок. Отправщики пишут
// в базу, поэтому Close обязан случиться до её закрытия.
func (h *Handler) Close() {
h.CloseStreams()
h.push.Close()
}
@@ -64,16 +75,23 @@ func New(cfg *config.Config, st *store.Store, static http.Handler, logw io.Write
// на него, а не при постановке в очередь (ADR-023).
live := hub.New()
s := &server{
cfg: cfg,
st: st,
hub: live,
push: push.New(cfg, st, live.Connected, logw),
msgs: newBuckets(messagesPerMinute, messagesBurst),
logw: logw,
cfg: cfg,
st: st,
hub: live,
push: push.New(cfg, st, live.Connected, logw),
regs: newBuckets(registerRule),
logins: newBuckets(loginRule),
msgs: newBuckets(messagesRule),
writes: newBuckets(writesRule),
logw: logw,
}
fail := auth.Fail{Error: Error, Internal: s.internal}
// Сессия проверяется на всех непубличных маршрутах (docs/protocol.md).
private := auth.Require(st, fail)
// write — сессия плюс общий лимит изменяющих запросов (ADR-021).
// Под него идут все непубличные маршруты кроме чтений и отправки
// сообщений: у сообщений своё правило.
write := func(h http.HandlerFunc) http.Handler { return private(s.limitWrites(h)) }
mux := http.NewServeMux()
mux.HandleFunc("GET /healthz", healthz)
@@ -84,30 +102,33 @@ func New(cfg *config.Config, st *store.Store, static http.Handler, logw io.Write
mux.HandleFunc("POST /api/login", s.login)
mux.Handle("GET /api/me", private(http.HandlerFunc(s.me)))
mux.Handle("DELETE /api/me", private(http.HandlerFunc(s.deleteMe)))
mux.Handle("POST /api/logout", private(http.HandlerFunc(s.logout)))
mux.Handle("POST /api/password", private(http.HandlerFunc(s.password)))
mux.Handle("DELETE /api/me", write(s.deleteMe))
mux.Handle("POST /api/logout", write(s.logout))
mux.Handle("POST /api/password", write(s.password))
mux.Handle("GET /api/users/{nick}", private(http.HandlerFunc(s.user)))
mux.Handle("POST /api/devices", private(http.HandlerFunc(s.createDevice)))
mux.Handle("POST /api/devices", write(s.createDevice))
mux.Handle("GET /api/devices", private(http.HandlerFunc(s.devices)))
mux.Handle("DELETE /api/devices/{id}", private(http.HandlerFunc(s.deleteDevice)))
mux.Handle("PUT /api/devices/{id}/push", private(http.HandlerFunc(s.setPush)))
mux.Handle("DELETE /api/devices/{id}/push", private(http.HandlerFunc(s.deletePush)))
mux.Handle("DELETE /api/devices/{id}", write(s.deleteDevice))
mux.Handle("PUT /api/devices/{id}/push", write(s.setPush))
mux.Handle("DELETE /api/devices/{id}/push", write(s.deletePush))
mux.Handle("GET /api/contacts", private(http.HandlerFunc(s.contacts)))
mux.Handle("POST /api/contacts", private(http.HandlerFunc(s.addContact)))
mux.Handle("DELETE /api/contacts/{nick}", private(http.HandlerFunc(s.deleteContact)))
mux.Handle("POST /api/contacts", write(s.addContact))
mux.Handle("DELETE /api/contacts/{nick}", write(s.deleteContact))
mux.Handle("GET /api/rooms", private(http.HandlerFunc(s.rooms)))
mux.Handle("POST /api/rooms", private(http.HandlerFunc(s.createRoom)))
mux.Handle("POST /api/rooms/{id}/members", private(http.HandlerFunc(s.updateMembers)))
mux.Handle("POST /api/rooms/{id}/leave", private(http.HandlerFunc(s.leaveRoom)))
mux.Handle("DELETE /api/rooms/{id}", private(http.HandlerFunc(s.deleteRoom)))
mux.Handle("POST /api/rooms", write(s.createRoom))
mux.Handle("POST /api/rooms/{id}/members", write(s.updateMembers))
mux.Handle("POST /api/rooms/{id}/leave", write(s.leaveRoom))
mux.Handle("DELETE /api/rooms/{id}", write(s.deleteRoom))
mux.Handle("GET /api/events", private(http.HandlerFunc(s.events)))
// Сообщения считаются своим правилом, поэтому мимо write: лимит стоит
// в самом обработчике, там, где его место в порядке проверок
// (docs/protocol.md, «Сообщения»).
mux.Handle("POST /api/messages", private(http.HandlerFunc(s.sendMessage)))
mux.Handle("POST /api/ack", private(http.HandlerFunc(s.ack)))
mux.Handle("POST /api/ack", write(s.ack))
// Всё прочее под /api/ — 404, включая неподдерживаемый метод известного
// пути: кода 405 в протоколе нет (ADR-026). Этот маршрут заодно не даёт