Этап 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)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package api_test
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
@@ -41,14 +42,23 @@ func account(nick string) map[string]any {
|
||||
}
|
||||
}
|
||||
|
||||
// signUp регистрирует аккаунт и отдаёт cookie сессии.
|
||||
// signUp регистрирует аккаунт и отдаёт cookie сессии. Каждый ник приходит
|
||||
// со своего адреса: регистрация ограничена пятью в час на IP (ADR-021),
|
||||
// и общий адрес упирался бы в лимит на шестом аккаунте теста.
|
||||
func (e *env) signUp(nick string) *http.Cookie {
|
||||
e.t.Helper()
|
||||
rec := e.do(http.MethodPost, "/api/register", account(nick))
|
||||
rec := e.do(http.MethodPost, "/api/register", account(nick), fromNick(nick))
|
||||
expect(e.t, rec, http.StatusCreated, "")
|
||||
return e.cookie(rec)
|
||||
}
|
||||
|
||||
// fromNick — свой адрес соединения на каждый ник, лишь бы разный
|
||||
// и не loopback.
|
||||
func fromNick(nick string) func(*http.Request) {
|
||||
sum := sha256.Sum256([]byte(nick))
|
||||
return withRemote(fmt.Sprintf("198.51.%d.%d:41000", sum[0], sum[1]))
|
||||
}
|
||||
|
||||
func (e *env) cookie(rec *httptest.ResponseRecorder) *http.Cookie {
|
||||
e.t.Helper()
|
||||
for _, c := range rec.Result().Cookies() {
|
||||
|
||||
+47
-26
@@ -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). Этот маршрут заодно не даёт
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
@@ -20,9 +21,11 @@ import (
|
||||
const origin = "https://bare.test"
|
||||
|
||||
// env — сервер на временной базе плюс журнал, в который он пишет.
|
||||
// Обработчик хранится своим типом: тестам нужен не только ServeHTTP,
|
||||
// но и остановка — Close и CloseStreams.
|
||||
type env struct {
|
||||
t *testing.T
|
||||
h http.Handler
|
||||
h *api.Handler
|
||||
st *store.Store
|
||||
log *syncLog
|
||||
srv *httptest.Server
|
||||
@@ -143,6 +146,33 @@ func withDevice(id string) func(*http.Request) {
|
||||
return func(r *http.Request) { r.Header.Set("X-Device", id) }
|
||||
}
|
||||
|
||||
// withRemote — адрес, с которого пришло соединение. От него зависят лимиты
|
||||
// на IP (ADR-021); httptest ставит всем один и тот же.
|
||||
func withRemote(addr string) func(*http.Request) {
|
||||
return func(r *http.Request) { r.RemoteAddr = addr }
|
||||
}
|
||||
|
||||
// withRealIP — заголовок, который ставит nginx. Читается, только если
|
||||
// соединение пришло с loopback (ADR-055).
|
||||
func withRealIP(ip string) func(*http.Request) {
|
||||
return func(r *http.Request) { r.Header.Set("X-Real-IP", ip) }
|
||||
}
|
||||
|
||||
// retryAfterOf — Retry-After ответа: целые секунды, не меньше одной
|
||||
// (docs/protocol.md, «Общие правила»).
|
||||
func retryAfterOf(t *testing.T, rec *httptest.ResponseRecorder) int {
|
||||
t.Helper()
|
||||
raw := rec.Header().Get("Retry-After")
|
||||
seconds, err := strconv.Atoi(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("Retry-After: получено %q, ожидались целые секунды", raw)
|
||||
}
|
||||
if seconds < 1 {
|
||||
t.Errorf("Retry-After: получено %d, ожидалось не меньше 1", seconds)
|
||||
}
|
||||
return seconds
|
||||
}
|
||||
|
||||
func withOrigin(value string) func(*http.Request) {
|
||||
return func(r *http.Request) {
|
||||
if value == "" {
|
||||
|
||||
@@ -9,6 +9,8 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/xmatic-squad/bare/internal/config"
|
||||
)
|
||||
|
||||
// wait — сколько тест ждёт события. Всё локально, задержек быть не должно.
|
||||
@@ -269,6 +271,43 @@ func TestEventsClosedOnDeviceDelete(t *testing.T) {
|
||||
s.ended()
|
||||
}
|
||||
|
||||
// Смена пароля с logoutOthers закрывает потоки отозванных сессий:
|
||||
// поток проверяет сессию только при подключении, и без этого отозванное
|
||||
// устройство продолжало бы получать конверты (ADR-058).
|
||||
func TestEventsClosedOnLogoutOthers(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
first, d1 := e.join("marta", 1)
|
||||
|
||||
login := e.do(http.MethodPost, "/api/login", map[string]any{"nick": "marta", "authKey": bytesOf(32, 1)})
|
||||
expect(t, login, http.StatusOK, "")
|
||||
second := e.cookie(login)
|
||||
d2 := e.addDevice(second, deviceOf(2))
|
||||
|
||||
revoked := e.open(d1, first)
|
||||
revoked.untilReady()
|
||||
kept := e.open(d2, second)
|
||||
kept.untilReady()
|
||||
|
||||
expect(t, e.do(http.MethodPost, "/api/password", map[string]any{
|
||||
"authKey": bytesOf(32, 1),
|
||||
"newAuthKey": bytesOf(32, 9),
|
||||
"blob": blobOf(config.KDFIterations),
|
||||
"logoutOthers": true,
|
||||
}, with(second)), http.StatusNoContent, "")
|
||||
|
||||
revoked.ended()
|
||||
|
||||
// Поток той сессии, ради которой всё затевалось, остаётся живым.
|
||||
select {
|
||||
case ev, ok := <-kept.events:
|
||||
if !ok {
|
||||
t.Fatal("закрылся поток текущей сессии")
|
||||
}
|
||||
t.Fatalf("лишнее событие текущей сессии: %+v", ev)
|
||||
case <-time.After(200 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
|
||||
// Чужое устройство в query — 403 unknown_device, поток не открывается.
|
||||
func TestEventsUnknownDevice(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
|
||||
+136
-32
@@ -2,27 +2,65 @@ package api
|
||||
|
||||
import (
|
||||
"math"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/xmatic-squad/bare/internal/auth"
|
||||
)
|
||||
|
||||
// Лимит сообщений (ADR-021): 30 в минуту на пользователя, пакет 10.
|
||||
// Остальные лимиты — этап 6.
|
||||
const (
|
||||
messagesPerMinute = 30
|
||||
messagesBurst = 10
|
||||
// Лимиты ADR-021, все четыре правила. Token bucket в памяти сервера:
|
||||
// рестарт их обнуляет — для маленького сервера это принято.
|
||||
//
|
||||
// Пакет отдельным числом задан только у сообщений. У остальных правил он
|
||||
// равен самому лимиту: «5 в час» означает, что за час набегает пять
|
||||
// попыток и потратить их можно разом (ADR-055).
|
||||
var (
|
||||
// registerRule — регистрация: 5 в час на IP.
|
||||
registerRule = rule{count: 5, window: time.Hour, burst: 5}
|
||||
// loginRule — вход: 10 за 10 минут на пару IP+ник.
|
||||
loginRule = rule{count: 10, window: 10 * time.Minute, burst: 10}
|
||||
// messagesRule — сообщения: 30 в минуту на пользователя, пакет 10.
|
||||
messagesRule = rule{count: 30, window: time.Minute, burst: 10}
|
||||
// writesRule — остальные изменяющие запросы: 60 в минуту
|
||||
// на пользователя.
|
||||
writesRule = rule{count: 60, window: time.Minute, burst: 60}
|
||||
)
|
||||
|
||||
// sweepAt — с какого размера карты имеет смысл выкидывать полные вёдра.
|
||||
const sweepAt = 1024
|
||||
// rule — правило лимита: count запросов за window, пакетом не больше burst.
|
||||
type rule struct {
|
||||
count int
|
||||
window time.Duration
|
||||
burst int
|
||||
}
|
||||
|
||||
// buckets — token bucket в памяти сервера, по ведру на ключ (ник).
|
||||
// Рестарт обнуляет лимиты: для маленького сервера это принято (ADR-021).
|
||||
// generation — сколько ключей карта лимита держит до смены поколения.
|
||||
//
|
||||
// Ведро заводится на каждый новый ключ, а ключ — это чужой адрес или чужой
|
||||
// ник: их бывает сколько угодно. Выбрасывать полные вёдра мало: под потоком
|
||||
// новых ключей полных не бывает вовсе — каждое только что потратило токен.
|
||||
// Поэтому карты две, нынешняя и прежняя. Как только нынешняя дорастает до
|
||||
// generation, она становится прежней, а прежняя выбрасывается целиком.
|
||||
// Ключ, по которому продолжают ходить, переезжает в нынешнюю и смену
|
||||
// переживает; забывается только то, к чему не обращались целое поколение,
|
||||
// а забытое ведро — то же самое, что новое.
|
||||
//
|
||||
// Отсюда предел: обе карты вместе держат не больше 2×generation вёдер,
|
||||
// то есть около мегабайта на правило. Миллион разных адресов памяти
|
||||
// не съедает — он протачивает поколения насквозь.
|
||||
const generation = 4096
|
||||
|
||||
// buckets — token bucket в памяти сервера, по ведру на ключ.
|
||||
type buckets struct {
|
||||
mu sync.Mutex
|
||||
rate float64 // токенов в секунду
|
||||
burst float64
|
||||
seen map[string]*bucket
|
||||
cur map[string]*bucket // нынешнее поколение
|
||||
old map[string]*bucket // прежнее, пока к его ключам ещё обращаются
|
||||
}
|
||||
|
||||
type bucket struct {
|
||||
@@ -30,11 +68,11 @@ type bucket struct {
|
||||
at time.Time
|
||||
}
|
||||
|
||||
func newBuckets(perMinute, burst int) *buckets {
|
||||
func newBuckets(r rule) *buckets {
|
||||
return &buckets{
|
||||
rate: float64(perMinute) / 60,
|
||||
burst: float64(burst),
|
||||
seen: make(map[string]*bucket),
|
||||
rate: float64(r.count) / r.window.Seconds(),
|
||||
burst: float64(r.burst),
|
||||
cur: make(map[string]*bucket),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,14 +82,7 @@ func (b *buckets) take(key string, now time.Time) (time.Duration, bool) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
|
||||
e, ok := b.seen[key]
|
||||
if !ok {
|
||||
if len(b.seen) >= sweepAt {
|
||||
b.sweep(now)
|
||||
}
|
||||
e = &bucket{tokens: b.burst, at: now}
|
||||
b.seen[key] = e
|
||||
}
|
||||
e := b.bucket(key, now)
|
||||
e.tokens = math.Min(b.burst, e.tokens+b.refill(e.at, now))
|
||||
e.at = now
|
||||
if e.tokens < 1 {
|
||||
@@ -61,6 +92,27 @@ func (b *buckets) take(key string, now time.Time) (time.Duration, bool) {
|
||||
return 0, true
|
||||
}
|
||||
|
||||
// bucket находит ведро ключа или заводит новое. Смена поколения идёт
|
||||
// до поиска: так в нынешней карте никогда не больше generation ключей,
|
||||
// а в обеих вместе — не больше двух таких карт.
|
||||
func (b *buckets) bucket(key string, now time.Time) *bucket {
|
||||
if len(b.cur) >= generation {
|
||||
b.old = b.cur
|
||||
b.cur = make(map[string]*bucket, generation)
|
||||
}
|
||||
if e, ok := b.cur[key]; ok {
|
||||
return e
|
||||
}
|
||||
if e, ok := b.old[key]; ok {
|
||||
delete(b.old, key)
|
||||
b.cur[key] = e
|
||||
return e
|
||||
}
|
||||
e := &bucket{tokens: b.burst, at: now}
|
||||
b.cur[key] = e
|
||||
return e
|
||||
}
|
||||
|
||||
// refill — сколько токенов набежало. Время назад не идёт: часы могли
|
||||
// прыгнуть, но долг за это выставлять некому.
|
||||
func (b *buckets) refill(since, now time.Time) float64 {
|
||||
@@ -71,16 +123,6 @@ func (b *buckets) refill(since, now time.Time) float64 {
|
||||
return d.Seconds() * b.rate
|
||||
}
|
||||
|
||||
// sweep выкидывает полные вёдра: они уже ничего не помнят. Иначе карта
|
||||
// росла бы на каждый новый ник и не уменьшалась никогда.
|
||||
func (b *buckets) sweep(now time.Time) {
|
||||
for key, e := range b.seen {
|
||||
if e.tokens+b.refill(e.at, now) >= b.burst {
|
||||
delete(b.seen, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// retryAfter — значение заголовка в секундах, не меньше одной: нулевое
|
||||
// ожидание после отказа сбивало бы клиента с толку.
|
||||
func retryAfter(wait time.Duration) int {
|
||||
@@ -89,3 +131,65 @@ func retryAfter(wait time.Duration) int {
|
||||
}
|
||||
return int(math.Ceil(wait.Seconds()))
|
||||
}
|
||||
|
||||
// rateLimited — 429 с Retry-After в целых секундах (ADR-021).
|
||||
func (s *server) rateLimited(w http.ResponseWriter, wait time.Duration) {
|
||||
w.Header().Set("Retry-After", strconv.Itoa(retryAfter(wait)))
|
||||
Error(w, http.StatusTooManyRequests, "rate_limited", "слишком часто, попробуйте позже")
|
||||
}
|
||||
|
||||
// limitWrites — общий лимит изменяющих запросов: 60 в минуту
|
||||
// на пользователя (ADR-021). Стоит на маршруте, а не в обработчике,
|
||||
// поэтому отвечает раньше разбора тела: смысл лимита в том, чтобы сервер
|
||||
// не брался за работу, а разбор тела — уже работа. Форму это не обгоняет
|
||||
// в смысле ADR-043: 429 говорит не о правах и не о существовании
|
||||
// сущностей, а о частоте.
|
||||
//
|
||||
// Сообщения сюда не входят: у них своё правило, своё ведро и своё место
|
||||
// в порядке проверок (docs/protocol.md, «Сообщения»).
|
||||
func (s *server) limitWrites(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
sess, _ := auth.From(r)
|
||||
if wait, ok := s.writes.take(sess.Nick, time.Now()); !ok {
|
||||
s.rateLimited(w, wait)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// clientIP — ключ лимитов, привязанных к адресу.
|
||||
//
|
||||
// X-Real-IP ставит nginx на той же машине (ADR-022), и верить заголовку
|
||||
// можно только тогда, когда соединение пришло оттуда же. Иначе его
|
||||
// подставит кто угодно: новая строка в заголовке — новое ведро, и лимита
|
||||
// на IP не существует вовсе. Соединение не с loopback — заголовок
|
||||
// не читается, ключом становится адрес соединения.
|
||||
func clientIP(r *http.Request) string {
|
||||
remote := connIP(r.RemoteAddr)
|
||||
if !remote.IsValid() {
|
||||
// Адрес соединения не разобрать. Одно общее ведро на всех —
|
||||
// лучше, чем ни одного.
|
||||
return r.RemoteAddr
|
||||
}
|
||||
if remote.IsLoopback() {
|
||||
if ip, err := netip.ParseAddr(strings.TrimSpace(r.Header.Get("X-Real-IP"))); err == nil {
|
||||
return ip.Unmap().WithZone("").String()
|
||||
}
|
||||
}
|
||||
return remote.String()
|
||||
}
|
||||
|
||||
// connIP — адрес, с которого пришло соединение. Невалидный Addr означает,
|
||||
// что RemoteAddr не разобрать.
|
||||
func connIP(remote string) netip.Addr {
|
||||
host, _, err := net.SplitHostPort(remote)
|
||||
if err != nil {
|
||||
host = remote
|
||||
}
|
||||
ip, err := netip.ParseAddr(host)
|
||||
if err != nil {
|
||||
return netip.Addr{}
|
||||
}
|
||||
return ip.Unmap().WithZone("")
|
||||
}
|
||||
|
||||
+125
-52
@@ -1,62 +1,80 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Token bucket из ADR-021: 30 в минуту, пакет 10.
|
||||
func TestBuckets(t *testing.T) {
|
||||
b := newBuckets(messagesPerMinute, messagesBurst)
|
||||
now := time.Now()
|
||||
// Все четыре правила ADR-021: пакет расходуется целиком, следующий токен
|
||||
// набегает ровно через window/count, ведро не переполняется.
|
||||
func TestRules(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
rule rule
|
||||
// token — сколько ждать одного токена на пустом ведре.
|
||||
token time.Duration
|
||||
}{
|
||||
{"регистрация", registerRule, 12 * time.Minute},
|
||||
{"вход", loginRule, time.Minute},
|
||||
{"сообщения", messagesRule, 2 * time.Second},
|
||||
{"изменяющие", writesRule, time.Second},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
b := newBuckets(c.rule)
|
||||
now := time.Now()
|
||||
|
||||
for i := 0; i < messagesBurst; i++ {
|
||||
if _, ok := b.take("marta", now); !ok {
|
||||
t.Fatalf("запрос %d из пакета отклонён", i+1)
|
||||
}
|
||||
}
|
||||
wait, ok := b.take("marta", now)
|
||||
if ok {
|
||||
t.Fatal("пакет не кончился")
|
||||
}
|
||||
// Тридцать в минуту — токен раз в две секунды.
|
||||
if wait != 2*time.Second {
|
||||
t.Errorf("ожидание: получено %v, ожидалось 2s", wait)
|
||||
}
|
||||
if got := retryAfter(wait); got != 2 {
|
||||
t.Errorf("Retry-After: получено %d, ожидалось 2", got)
|
||||
}
|
||||
for i := 0; i < c.rule.burst; i++ {
|
||||
if _, ok := b.take("ключ", now); !ok {
|
||||
t.Fatalf("запрос %d из пакета %d отклонён", i+1, c.rule.burst)
|
||||
}
|
||||
}
|
||||
wait, ok := b.take("ключ", now)
|
||||
if ok {
|
||||
t.Fatal("пакет не кончился")
|
||||
}
|
||||
if wait != c.token {
|
||||
t.Errorf("ожидание: получено %v, ожидалось %v", wait, c.token)
|
||||
}
|
||||
if got, want := retryAfter(wait), int(c.token.Seconds()); got != want {
|
||||
t.Errorf("Retry-After: получено %d, ожидалось %d", got, want)
|
||||
}
|
||||
|
||||
// Через две секунды набегает ровно один токен.
|
||||
if _, ok := b.take("marta", now.Add(2*time.Second)); !ok {
|
||||
t.Error("токен не набежал")
|
||||
}
|
||||
if _, ok := b.take("marta", now.Add(2*time.Second)); ok {
|
||||
t.Error("набежало больше одного токена")
|
||||
}
|
||||
// Восстановление: ровно через это время набегает ровно один токен.
|
||||
if _, ok := b.take("ключ", now.Add(c.token)); !ok {
|
||||
t.Error("токен не набежал")
|
||||
}
|
||||
if _, ok := b.take("ключ", now.Add(c.token)); ok {
|
||||
t.Error("набежало больше одного токена")
|
||||
}
|
||||
|
||||
// Ведро не переполняется: за час копится пакет, не тридцать в минуту.
|
||||
for i := 0; i < messagesBurst; i++ {
|
||||
if _, ok := b.take("marta", now.Add(time.Hour)); !ok {
|
||||
t.Fatalf("запрос %d после долгой паузы отклонён", i+1)
|
||||
}
|
||||
}
|
||||
if _, ok := b.take("marta", now.Add(time.Hour)); ok {
|
||||
t.Error("ведро больше пакета")
|
||||
}
|
||||
// За долгую паузу копится пакет, а не весь пропущенный поток.
|
||||
for i := 0; i < c.rule.burst; i++ {
|
||||
if _, ok := b.take("ключ", now.Add(24*time.Hour)); !ok {
|
||||
t.Fatalf("запрос %d после долгой паузы отклонён", i+1)
|
||||
}
|
||||
}
|
||||
if _, ok := b.take("ключ", now.Add(24*time.Hour)); ok {
|
||||
t.Error("ведро больше пакета")
|
||||
}
|
||||
|
||||
// Лимит на ключ: чужое ведро полное.
|
||||
if _, ok := b.take("petya", now); !ok {
|
||||
t.Error("лимит одного пользователя задел другого")
|
||||
// Ведро на ключ: чужое полное.
|
||||
if _, ok := b.take("другой ключ", now); !ok {
|
||||
t.Error("лимит одного ключа задел другой")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Часы могут прыгнуть назад; долг за это никому не выставляется.
|
||||
func TestBucketsClockBack(t *testing.T) {
|
||||
b := newBuckets(messagesPerMinute, messagesBurst)
|
||||
b := newBuckets(messagesRule)
|
||||
now := time.Now()
|
||||
|
||||
for i := 0; i < messagesBurst; i++ {
|
||||
for i := 0; i < messagesRule.burst; i++ {
|
||||
b.take("marta", now)
|
||||
}
|
||||
if _, ok := b.take("marta", now.Add(-time.Hour)); ok {
|
||||
@@ -64,32 +82,43 @@ func TestBucketsClockBack(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Полные вёдра выкидываются: карта не растёт на каждый ник навсегда.
|
||||
func TestBucketsSweep(t *testing.T) {
|
||||
b := newBuckets(messagesPerMinute, messagesBurst)
|
||||
// Карта лимита не растёт бесконечно: миллион разных ключей проходит
|
||||
// сквозь поколения, а вёдер остаётся не больше двух карт.
|
||||
func TestBucketsBounded(t *testing.T) {
|
||||
b := newBuckets(registerRule)
|
||||
now := time.Now()
|
||||
|
||||
for i := 0; i < sweepAt; i++ {
|
||||
b.take(string(rune(i)), now)
|
||||
for i := 0; i < 1_000_000; i++ {
|
||||
b.take(strconv.Itoa(i), now)
|
||||
}
|
||||
if len(b.seen) != sweepAt {
|
||||
t.Fatalf("вёдер: получено %d, ожидалось %d", len(b.seen), sweepAt)
|
||||
if got := b.size(); got > 2*generation {
|
||||
t.Errorf("вёдер: получено %d, ожидалось не больше %d", got, 2*generation)
|
||||
}
|
||||
// Все вёдра успели наполниться заново — чистка их и уносит.
|
||||
b.take("marta", now.Add(time.Hour))
|
||||
if len(b.seen) != 1 {
|
||||
t.Errorf("вёдер после чистки: получено %d, ожидалось 1", len(b.seen))
|
||||
|
||||
// Ключ, по которому ходят, смену поколения переживает: его ведро
|
||||
// переезжает в нынешнюю карту, а не заводится заново.
|
||||
b = newBuckets(registerRule)
|
||||
for i := 0; i < registerRule.burst; i++ {
|
||||
b.take("свой", now)
|
||||
}
|
||||
for i := 0; i < 3*generation; i++ {
|
||||
b.take(strconv.Itoa(i), now)
|
||||
if _, ok := b.take("свой", now); ok {
|
||||
t.Fatalf("ведро забыто на %d-м чужом ключе", i+1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Ждать меньше секунды бессмысленно: Retry-After в секундах.
|
||||
func TestRetryAfter(t *testing.T) {
|
||||
cases := map[time.Duration]int{
|
||||
-time.Second: 1,
|
||||
0: 1,
|
||||
100 * time.Millisecond: 1,
|
||||
time.Second: 1,
|
||||
1500 * time.Millisecond: 2,
|
||||
2 * time.Second: 2,
|
||||
12 * time.Minute: 720,
|
||||
}
|
||||
for wait, want := range cases {
|
||||
if got := retryAfter(wait); got != want {
|
||||
@@ -98,6 +127,42 @@ func TestRetryAfter(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// X-Real-IP ставит nginx с той же машины (ADR-022). Заголовку из сети
|
||||
// веры нет: иначе лимит на IP снимался бы новой строкой в заголовке.
|
||||
func TestClientIP(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
remote string
|
||||
real string
|
||||
want string
|
||||
}{
|
||||
{"без заголовка", "203.0.113.7:41000", "", "203.0.113.7"},
|
||||
{"заголовок из сети", "203.0.113.7:41000", "198.51.100.1", "203.0.113.7"},
|
||||
{"заголовок от nginx", "127.0.0.1:41000", "198.51.100.1", "198.51.100.1"},
|
||||
{"nginx по ipv6", "[::1]:41000", "198.51.100.1", "198.51.100.1"},
|
||||
{"loopback без заголовка", "127.0.0.1:41000", "", "127.0.0.1"},
|
||||
{"мусор в заголовке", "127.0.0.1:41000", "не адрес", "127.0.0.1"},
|
||||
{"пробелы в заголовке", "127.0.0.1:41000", " 198.51.100.1 ", "198.51.100.1"},
|
||||
{"адрес с портом в заголовке", "127.0.0.1:41000", "198.51.100.1:80", "127.0.0.1"},
|
||||
{"ipv6 клиента", "[2001:db8::1]:41000", "", "2001:db8::1"},
|
||||
{"ipv4 в ipv6-форме", "[::ffff:203.0.113.7]:41000", "", "203.0.113.7"},
|
||||
{"ipv4 в ipv6-форме в заголовке", "127.0.0.1:41000", "::ffff:198.51.100.1", "198.51.100.1"},
|
||||
{"не разобрать соединение", "сокет", "198.51.100.1", "сокет"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
r := httptest.NewRequest(http.MethodPost, "/api/register", nil)
|
||||
r.RemoteAddr = c.remote
|
||||
if c.real != "" {
|
||||
r.Header.Set("X-Real-IP", c.real)
|
||||
}
|
||||
if got := clientIP(r); got != c.want {
|
||||
t.Errorf("clientIP: получено %q, ожидалось %q", got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ULID: 26 символов Crockford base32, время в первых десяти.
|
||||
func TestULIDTime(t *testing.T) {
|
||||
// 01ARZ3NDEK — 2016-07-30T23:54:10.259Z.
|
||||
@@ -119,3 +184,11 @@ func TestULIDTime(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// size — сколько вёдер помнят обе карты. Только для тестов: предел размера
|
||||
// проверяется, а не подразумевается.
|
||||
func (b *buckets) size() int {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
return len(b.cur) + len(b.old)
|
||||
}
|
||||
|
||||
+13
-15
@@ -3,7 +3,6 @@ package api
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/xmatic-squad/bare/internal/auth"
|
||||
@@ -55,10 +54,6 @@ type messageIn struct {
|
||||
// он проверяет форму и раскладывает конверт по очередям (ADR-008).
|
||||
// Порядок проверок — docs/protocol.md, «Сообщения».
|
||||
func (s *server) sendMessage(w http.ResponseWriter, r *http.Request) {
|
||||
device, ok := s.device(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var in messageIn
|
||||
if !decode(w, r, &in) {
|
||||
return
|
||||
@@ -75,6 +70,14 @@ func (s *server) sendMessage(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Принадлежность устройства — право, а не форма, поэтому проверяется
|
||||
// после разбора тела: кривое тело отвечает bad_json и invalid даже
|
||||
// с чужим X-Device (ADR-043).
|
||||
device, ok := s.device(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
sess, _ := auth.From(r)
|
||||
room := in.To.Room != ""
|
||||
// Заголовок и адрес чата для пуша: сервер собирает их из того, что
|
||||
@@ -220,10 +223,6 @@ func checkForm(w http.ResponseWriter, in messageIn) (int64, bool) {
|
||||
// POST /api/ack — клиент записал сообщения в IndexedDB: из очереди
|
||||
// устройства их можно убрать (ADR-008).
|
||||
func (s *server) ack(w http.ResponseWriter, r *http.Request) {
|
||||
device, ok := s.device(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var in struct {
|
||||
IDs []string `json:"ids"`
|
||||
}
|
||||
@@ -234,15 +233,14 @@ func (s *server) ack(w http.ResponseWriter, r *http.Request) {
|
||||
Invalid(w, "ids", "не больше 500 идентификаторов")
|
||||
return
|
||||
}
|
||||
// Устройство — право: после формы тела (ADR-043).
|
||||
device, ok := s.device(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := s.st.Ack(r.Context(), device, in.IDs); err != nil {
|
||||
s.internal(w, r, err)
|
||||
return
|
||||
}
|
||||
noContent(w)
|
||||
}
|
||||
|
||||
// rateLimited — 429 с Retry-After в секундах (ADR-021).
|
||||
func (s *server) rateLimited(w http.ResponseWriter, wait time.Duration) {
|
||||
w.Header().Set("Retry-After", strconv.Itoa(retryAfter(wait)))
|
||||
Error(w, http.StatusTooManyRequests, "rate_limited", "слишком часто, попробуйте позже")
|
||||
}
|
||||
|
||||
@@ -804,3 +804,39 @@ func TestPushPayloadPerDevice(t *testing.T) {
|
||||
}
|
||||
svc.silent()
|
||||
}
|
||||
|
||||
// Остановка обработчика дожидается начатых отправок. Отправщик пишет
|
||||
// результат в базу, поэтому закрывать её раньше нельзя, а колбэк
|
||||
// http.Server.RegisterOnShutdown для этого не годится: сервер запускает
|
||||
// его в своей горутине и ничего не ждёт. Потоки событий там закрывает
|
||||
// CloseStreams, отправку останавливает Close — после Shutdown.
|
||||
func TestCloseWaitsForPush(t *testing.T) {
|
||||
svc, release := newSlowPushService(t)
|
||||
e := pushEnv(t)
|
||||
marta, m1 := e.join("marta", 1)
|
||||
petya, p1 := e.join("petya", 2)
|
||||
e.subscribe("petya", p1, svc)
|
||||
_ = petya
|
||||
|
||||
e.send(marta, m1, "petya", 5)
|
||||
// Отправка началась и висит на медленном сервисе.
|
||||
svc.next()
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
e.h.Close()
|
||||
}()
|
||||
select {
|
||||
case <-done:
|
||||
t.Fatal("остановка не дождалась начатой отправки")
|
||||
case <-time.After(quiet):
|
||||
}
|
||||
|
||||
release()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(wait):
|
||||
t.Fatal("остановка не закончилась после отправки")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
package api_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/xmatic-squad/bare/internal/api"
|
||||
)
|
||||
|
||||
// nickOf — ник для очередного аккаунта теста.
|
||||
func nickOf(i int) string { return fmt.Sprintf("marta%d", i) }
|
||||
|
||||
// fromIP — соединение с этого адреса, без заголовков.
|
||||
func fromIP(ip string) func(*http.Request) { return withRemote(ip + ":41000") }
|
||||
|
||||
// Регистрация — 5 в час на IP (ADR-021).
|
||||
func TestRegisterRateLimit(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
one := fromIP("203.0.113.7")
|
||||
|
||||
for i := 0; i < 5; i++ {
|
||||
expect(t, e.do(http.MethodPost, "/api/register", account(nickOf(i)), one), http.StatusCreated, "")
|
||||
}
|
||||
rec := e.do(http.MethodPost, "/api/register", account("kolya"), one)
|
||||
expect(t, rec, http.StatusTooManyRequests, "rate_limited")
|
||||
// Токен набегает раз в двенадцать минут; ведро пусто, значит ждать
|
||||
// почти все 720 секунд.
|
||||
if got := retryAfterOf(t, rec); got < 700 || got > 720 {
|
||||
t.Errorf("Retry-After: получено %d, ожидалось около 720", got)
|
||||
}
|
||||
// Отказ ничего не завёл.
|
||||
expect(t, e.do(http.MethodPost, "/api/login", map[string]any{"nick": "kolya", "authKey": bytesOf(32, 1)}),
|
||||
http.StatusUnauthorized, "invalid_credentials")
|
||||
|
||||
// Другой адрес — своё ведро.
|
||||
expect(t, e.do(http.MethodPost, "/api/register", account("kolya"), fromIP("203.0.113.8")),
|
||||
http.StatusCreated, "")
|
||||
}
|
||||
|
||||
// Неудачная регистрация тратит попытку так же, как удачная: иначе занятые
|
||||
// ники перебирались бы без счёта.
|
||||
func TestRegisterRateLimitCountsFailures(t *testing.T) {
|
||||
e := invited(t, "секрет")
|
||||
one := fromIP("203.0.113.7")
|
||||
|
||||
for i := 0; i < 5; i++ {
|
||||
body := account(nickOf(i))
|
||||
body["invite"] = "не секрет"
|
||||
expect(t, e.do(http.MethodPost, "/api/register", body, one), http.StatusForbidden, "invalid_invite")
|
||||
}
|
||||
right := account("marta")
|
||||
right["invite"] = "секрет"
|
||||
expect(t, e.do(http.MethodPost, "/api/register", right, one), http.StatusTooManyRequests, "rate_limited")
|
||||
|
||||
// Форма разбирается раньше лимита и попытки не тратит (ADR-043).
|
||||
fresh := fromIP("203.0.113.9")
|
||||
for i := 0; i < 20; i++ {
|
||||
body := account("МАРТА")
|
||||
body["invite"] = "секрет"
|
||||
expect(t, e.do(http.MethodPost, "/api/register", body, fresh), http.StatusBadRequest, "invalid_nick")
|
||||
}
|
||||
expect(t, e.do(http.MethodPost, "/api/register", right, fresh), http.StatusCreated, "")
|
||||
}
|
||||
|
||||
// Вход — 10 за 10 минут на пару IP+ник (ADR-021).
|
||||
func TestLoginRateLimit(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.signUp("marta")
|
||||
e.signUp("petya")
|
||||
|
||||
one := fromIP("203.0.113.7")
|
||||
wrong := map[string]any{"nick": "marta", "authKey": bytesOf(32, 9)}
|
||||
for i := 0; i < 10; i++ {
|
||||
expect(t, e.do(http.MethodPost, "/api/login", wrong, one), http.StatusUnauthorized, "invalid_credentials")
|
||||
}
|
||||
rec := e.do(http.MethodPost, "/api/login", wrong, one)
|
||||
expect(t, rec, http.StatusTooManyRequests, "rate_limited")
|
||||
if got := retryAfterOf(t, rec); got < 55 || got > 60 {
|
||||
t.Errorf("Retry-After: получено %d, ожидалось около 60", got)
|
||||
}
|
||||
|
||||
// Верный пароль с того же адреса ждёт вместе с неверными: ведро
|
||||
// на паре, а не на исходе попытки.
|
||||
right := map[string]any{"nick": "marta", "authKey": bytesOf(32, 1)}
|
||||
expect(t, e.do(http.MethodPost, "/api/login", right, one), http.StatusTooManyRequests, "rate_limited")
|
||||
|
||||
// Другой ник с того же адреса — своё ведро.
|
||||
expect(t, e.do(http.MethodPost, "/api/login", map[string]any{"nick": "petya", "authKey": bytesOf(32, 9)}, one),
|
||||
http.StatusUnauthorized, "invalid_credentials")
|
||||
// Тот же ник с другого адреса — тоже своё.
|
||||
expect(t, e.do(http.MethodPost, "/api/login", right, fromIP("203.0.113.8")), http.StatusOK, "")
|
||||
}
|
||||
|
||||
// Остальные изменяющие запросы — 60 в минуту на пользователя (ADR-021).
|
||||
func TestWritesRateLimit(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
marta := e.signUp("marta")
|
||||
id := deviceOf(1)
|
||||
|
||||
for i := 0; i < 60; i++ {
|
||||
rec := e.do(http.MethodPost, "/api/devices", map[string]any{"id": id}, with(marta))
|
||||
if rec.Code >= http.StatusMultipleChoices {
|
||||
t.Fatalf("запрос %d из шестидесяти отклонён: %d (%s)", i+1, rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
rec := e.do(http.MethodPost, "/api/devices", map[string]any{"id": id}, with(marta))
|
||||
expect(t, rec, http.StatusTooManyRequests, "rate_limited")
|
||||
// Шестьдесят в минуту — токен раз в секунду.
|
||||
if got := retryAfterOf(t, rec); got != 1 {
|
||||
t.Errorf("Retry-After: получено %d, ожидалось 1", got)
|
||||
}
|
||||
|
||||
// Ведро общее на все изменяющие маршруты пользователя.
|
||||
expect(t, e.do(http.MethodPost, "/api/contacts", map[string]any{"nick": "petya"}, with(marta)),
|
||||
http.StatusTooManyRequests, "rate_limited")
|
||||
expect(t, e.do(http.MethodPost, "/api/logout", nil, with(marta)),
|
||||
http.StatusTooManyRequests, "rate_limited")
|
||||
|
||||
// Чтения лимитом не считаются: ADR-021 ограничивает изменяющие.
|
||||
expect(t, e.do(http.MethodGet, "/api/devices", nil, with(marta)), http.StatusOK, "")
|
||||
expect(t, e.do(http.MethodGet, "/api/me", nil, with(marta)), http.StatusOK, "")
|
||||
expect(t, e.do(http.MethodGet, "/api/rooms", nil, with(marta)), http.StatusOK, "")
|
||||
|
||||
// Сообщения считаются своим правилом и своим ведром.
|
||||
petya := e.signUp("petya")
|
||||
e.addDevice(petya, deviceOf(2))
|
||||
expect(t, e.do(http.MethodPost, "/api/messages", message(ulid(nowMillis(), 3), "petya"),
|
||||
with(marta), withDevice(id)), http.StatusAccepted, "")
|
||||
|
||||
// Другой пользователь чужим лимитом не задет. Строка контакта уже есть:
|
||||
// её завело сообщение, поэтому 200, а не 201 (ADR-019).
|
||||
expect(t, e.do(http.MethodPost, "/api/contacts", map[string]any{"nick": "marta"}, with(petya)),
|
||||
http.StatusOK, "")
|
||||
}
|
||||
|
||||
// Лимит сообщений считается отдельно от общего: тридцать в минуту
|
||||
// не отнимают шестьдесят у остальных запросов (ADR-021).
|
||||
func TestMessagesOutsideWritesLimit(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
marta, m1 := e.join("marta", 1)
|
||||
e.join("petya", 2)
|
||||
|
||||
for i := 0; i < 10; i++ {
|
||||
expect(t, e.do(http.MethodPost, "/api/messages", message(ulid(nowMillis(), byte(i)), "petya"),
|
||||
with(marta), withDevice(m1)), http.StatusAccepted, "")
|
||||
}
|
||||
expect(t, e.do(http.MethodPost, "/api/messages", message(ulid(nowMillis(), 11), "petya"),
|
||||
with(marta), withDevice(m1)), http.StatusTooManyRequests, "rate_limited")
|
||||
// Пакет сообщений кончился, изменяющие запросы работают.
|
||||
expect(t, e.do(http.MethodPost, "/api/ack", map[string]any{"ids": []string{}}, with(marta), withDevice(m1)),
|
||||
http.StatusNoContent, "")
|
||||
expect(t, e.do(http.MethodPost, "/api/contacts", map[string]any{"nick": "petya"}, with(marta)),
|
||||
http.StatusOK, "")
|
||||
}
|
||||
|
||||
// X-Real-IP ставит nginx с той же машины: за ним у каждого адреса своё
|
||||
// ведро (ADR-055).
|
||||
func TestRealIPFromLoopback(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
nginx := fromIP("127.0.0.1")
|
||||
|
||||
for i := 0; i < 5; i++ {
|
||||
expect(t, e.do(http.MethodPost, "/api/register", account(nickOf(i)), nginx, withRealIP("198.51.100.7")),
|
||||
http.StatusCreated, "")
|
||||
}
|
||||
expect(t, e.do(http.MethodPost, "/api/register", account("kolya"), nginx, withRealIP("198.51.100.7")),
|
||||
http.StatusTooManyRequests, "rate_limited")
|
||||
// Соседний адрес за тем же nginx ждать не должен.
|
||||
expect(t, e.do(http.MethodPost, "/api/register", account("kolya"), nginx, withRealIP("198.51.100.8")),
|
||||
http.StatusCreated, "")
|
||||
}
|
||||
|
||||
// Заголовок из сети не читается: иначе лимит на IP снимался бы новой
|
||||
// строкой в заголовке, то есть не существовал бы вовсе (ADR-055).
|
||||
func TestRealIPFromNetworkIgnored(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
one := fromIP("203.0.113.7")
|
||||
|
||||
for i := 0; i < 5; i++ {
|
||||
expect(t, e.do(http.MethodPost, "/api/register", account(nickOf(i)), one,
|
||||
withRealIP(fmt.Sprintf("198.51.100.%d", i))), http.StatusCreated, "")
|
||||
}
|
||||
rec := e.do(http.MethodPost, "/api/register", account("kolya"), one, withRealIP("198.51.100.200"))
|
||||
expect(t, rec, http.StatusTooManyRequests, "rate_limited")
|
||||
|
||||
// И на входе тоже: ведро на паре адрес соединения + ник.
|
||||
e2 := newEnv(t)
|
||||
e2.signUp("marta")
|
||||
wrong := map[string]any{"nick": "marta", "authKey": bytesOf(32, 9)}
|
||||
for i := 0; i < 10; i++ {
|
||||
expect(t, e2.do(http.MethodPost, "/api/login", wrong, one, withRealIP(fmt.Sprintf("198.51.100.%d", i))),
|
||||
http.StatusUnauthorized, "invalid_credentials")
|
||||
}
|
||||
expect(t, e2.do(http.MethodPost, "/api/login", wrong, one, withRealIP("198.51.100.200")),
|
||||
http.StatusTooManyRequests, "rate_limited")
|
||||
}
|
||||
|
||||
// Предел тела — 32 КиБ на всех маршрутах, ответ один: 413 too_large
|
||||
// (ADR-026). Отказ приходит раньше сессии, устройства и разбора тела.
|
||||
func TestTooLargeEverywhere(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
marta, device := e.join("marta", 1)
|
||||
big := strings.Repeat("a", api.MaxBody+1)
|
||||
|
||||
routes := []struct{ method, target string }{
|
||||
{http.MethodPost, "/api/register"},
|
||||
{http.MethodPost, "/api/login"},
|
||||
{http.MethodPost, "/api/logout"},
|
||||
{http.MethodPost, "/api/password"},
|
||||
{http.MethodDelete, "/api/me"},
|
||||
{http.MethodPost, "/api/devices"},
|
||||
{http.MethodDelete, "/api/devices/" + device},
|
||||
{http.MethodPut, "/api/devices/" + device + "/push"},
|
||||
{http.MethodDelete, "/api/devices/" + device + "/push"},
|
||||
{http.MethodPost, "/api/contacts"},
|
||||
{http.MethodDelete, "/api/contacts/petya"},
|
||||
{http.MethodPost, "/api/rooms"},
|
||||
{http.MethodPost, "/api/rooms/" + roomIDOf(1) + "/members"},
|
||||
{http.MethodPost, "/api/rooms/" + roomIDOf(1) + "/leave"},
|
||||
{http.MethodDelete, "/api/rooms/" + roomIDOf(1)},
|
||||
{http.MethodPost, "/api/messages"},
|
||||
{http.MethodPost, "/api/ack"},
|
||||
{http.MethodGet, "/api/me"},
|
||||
{http.MethodGet, "/api/events?device=" + device},
|
||||
}
|
||||
for _, route := range routes {
|
||||
t.Run(route.method+" "+route.target, func(t *testing.T) {
|
||||
// С сессией и своим устройством — отказ всё равно по телу.
|
||||
expect(t, e.do(route.method, route.target, big, with(marta), withDevice(device)),
|
||||
http.StatusRequestEntityTooLarge, "too_large")
|
||||
// И без сессии тоже: тело проверяется раньше прав (ADR-043).
|
||||
expect(t, e.do(route.method, route.target, big),
|
||||
http.StatusRequestEntityTooLarge, "too_large")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Тело без заявленной длины обрывается при чтении — тем же кодом и там,
|
||||
// где раньше отвечало устройство (ADR-043).
|
||||
func TestTooLargeUnannounced(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
marta, _ := e.join("marta", 1)
|
||||
_, foreign := e.join("petya", 2)
|
||||
|
||||
// Валидный json, чтобы разбор дошёл до предела чтения, а не споткнулся
|
||||
// о первый же байт.
|
||||
body := `{"id":"` + strings.Repeat("a", api.MaxBody) + `"}`
|
||||
for _, target := range []string{"/api/messages", "/api/ack", "/api/rooms", "/api/devices", "/api/contacts"} {
|
||||
rec := e.do(http.MethodPost, target, nil, with(marta), withDevice(foreign), func(r *http.Request) {
|
||||
r.Body = io.NopCloser(strings.NewReader(body))
|
||||
r.ContentLength = -1
|
||||
})
|
||||
expect(t, rec, http.StatusRequestEntityTooLarge, "too_large")
|
||||
}
|
||||
}
|
||||
|
||||
// Форма запроса разбирается раньше прав: кривое тело с чужим устройством
|
||||
// отвечает про тело, а не про устройство (ADR-043).
|
||||
func TestFormBeforeDevice(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
_, martaDevice := e.join("marta", 1)
|
||||
petya, _ := e.join("petya", 2)
|
||||
|
||||
for _, target := range []string{"/api/messages", "/api/ack", "/api/rooms"} {
|
||||
expect(t, e.do(http.MethodPost, target, "{", with(petya), withDevice(martaDevice)),
|
||||
http.StatusBadRequest, "bad_json")
|
||||
// Заголовка нет вовсе — то же самое.
|
||||
expect(t, e.do(http.MethodPost, target, "{", with(petya)),
|
||||
http.StatusBadRequest, "bad_json")
|
||||
}
|
||||
|
||||
// Кривое поле тела — invalid с этим полем, хотя устройство чужое.
|
||||
rec := e.do(http.MethodPost, "/api/messages", message("не ulid", "marta"), with(petya), withDevice(martaDevice))
|
||||
expect(t, rec, http.StatusBadRequest, "invalid")
|
||||
if got := field(t, rec); got != "id" {
|
||||
t.Errorf("field: получено %q, ожидалось \"id\"", got)
|
||||
}
|
||||
// Часы — свойство самого запроса, а не право: clock_skew тоже раньше
|
||||
// (docs/protocol.md, «Сообщения»).
|
||||
expect(t, e.do(http.MethodPost, "/api/messages", message(ulid(nowMillis()-6*60*1000, 3), "marta"),
|
||||
with(petya), withDevice(martaDevice)), http.StatusBadRequest, "clock_skew")
|
||||
|
||||
ids := make([]string, 501)
|
||||
for i := range ids {
|
||||
ids[i] = ulid(nowMillis(), byte(i))
|
||||
}
|
||||
expect(t, e.do(http.MethodPost, "/api/ack", map[string]any{"ids": ids}, with(petya), withDevice(martaDevice)),
|
||||
http.StatusBadRequest, "invalid")
|
||||
|
||||
room := map[string]any{
|
||||
"id": "не комната",
|
||||
"name": "общая",
|
||||
"keyId": keyID(40),
|
||||
"keys": keysFor([]string{"petya"}, 40),
|
||||
}
|
||||
expect(t, e.do(http.MethodPost, "/api/rooms", room, with(petya), withDevice(martaDevice)),
|
||||
http.StatusBadRequest, "invalid")
|
||||
|
||||
// Тело по форме — тогда отказ по устройству.
|
||||
expect(t, e.do(http.MethodPost, "/api/messages", message(ulid(nowMillis(), 4), "marta"),
|
||||
with(petya), withDevice(martaDevice)), http.StatusForbidden, "unknown_device")
|
||||
}
|
||||
+37
-23
@@ -20,8 +20,10 @@ import (
|
||||
// метаданные, как и состав.
|
||||
const maxRoomName = 64
|
||||
|
||||
// roomOut — тип Room из docs/protocol.md. key присутствует всегда,
|
||||
// пустой — null; needsRekey — состояние комнаты, а не свойство события,
|
||||
// roomOut — тип Room из docs/protocol.md. keys присутствует всегда,
|
||||
// пустой — []; в GET /api/rooms это все удерживаемые сервером ключи
|
||||
// запрашивающего, от старого к новому, в событии room — только новый
|
||||
// (ADR-059). needsRekey — состояние комнаты, а не свойство события,
|
||||
// поэтому идёт и в списке, и в событии (ADR-041).
|
||||
type roomOut struct {
|
||||
ID string `json:"id"`
|
||||
@@ -29,7 +31,7 @@ type roomOut struct {
|
||||
Owner string `json:"owner"`
|
||||
Members []string `json:"members"`
|
||||
CreatedAt int64 `json:"createdAt"`
|
||||
Key *keyOut `json:"key"`
|
||||
Keys []keyOut `json:"keys"`
|
||||
NeedsRekey bool `json:"needsRekey"`
|
||||
}
|
||||
|
||||
@@ -49,8 +51,9 @@ type keyIn struct {
|
||||
}
|
||||
|
||||
// GET /api/rooms — комнаты, где пользователь участник, каждая с его
|
||||
// текущим ключом и признаком needsRekey: владелец, пропустивший событие,
|
||||
// поднимает долг по ключу отсюда (ADR-041).
|
||||
// ключами и признаком needsRekey: владелец, пропустивший событие,
|
||||
// поднимает долг по ключу отсюда (ADR-041), а участник, пропустивший
|
||||
// rekey в офлайне, — недостающий ключ (ADR-059).
|
||||
func (s *server) rooms(w http.ResponseWriter, r *http.Request) {
|
||||
sess, _ := auth.From(r)
|
||||
list, err := s.st.Rooms(r.Context(), sess.Nick)
|
||||
@@ -60,7 +63,7 @@ func (s *server) rooms(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
out := make([]roomOut, 0, len(list))
|
||||
for _, room := range list {
|
||||
out = append(out, roomJSON(room, room.Key))
|
||||
out = append(out, roomJSON(room, room.Keys))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
@@ -69,12 +72,6 @@ func (s *server) rooms(w http.ResponseWriter, r *http.Request) {
|
||||
// (ADR-037), ключ приходит ровно один и заворачивается создателем себе:
|
||||
// его другие устройства получают комнату вместе с ключом (ADR-018).
|
||||
func (s *server) createRoom(w http.ResponseWriter, r *http.Request) {
|
||||
// X-Device здесь необязателен, но чужой и кривой — 403, как и везде,
|
||||
// где устройство важно (docs/protocol.md, «Общие правила»).
|
||||
device, ok := s.optionalDevice(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var in struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
@@ -109,6 +106,13 @@ func (s *server) createRoom(w http.ResponseWriter, r *http.Request) {
|
||||
keysMismatch(w)
|
||||
return
|
||||
}
|
||||
// X-Device здесь необязателен, но чужой и кривой — 403, как и везде,
|
||||
// где устройство важно (docs/protocol.md, «Общие правила»). Проверка
|
||||
// идёт после формы тела: права — после неё (ADR-043).
|
||||
device, ok := s.optionalDevice(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
change, err := s.st.CreateRoom(r.Context(), store.NewRoom{
|
||||
ID: in.ID,
|
||||
Name: in.Name,
|
||||
@@ -130,7 +134,7 @@ func (s *server) createRoom(w http.ResponseWriter, r *http.Request) {
|
||||
// Комната уже записана: остальным устройствам создателя она уходит
|
||||
// событием, отправившему — ответом на запрос.
|
||||
s.sendRoom(r, change, device)
|
||||
writeJSON(w, http.StatusCreated, roomJSON(change.Room, keyFor(change, sess.Nick)))
|
||||
writeJSON(w, http.StatusCreated, roomJSON(change.Room, keysFor(change, sess.Nick)))
|
||||
}
|
||||
|
||||
// POST /api/rooms/{id}/members — смена состава и rekey одним запросом
|
||||
@@ -191,7 +195,7 @@ func (s *server) updateMembers(w http.ResponseWriter, r *http.Request) {
|
||||
// Событие room уходит и участникам, и — как room_left — убранным;
|
||||
// каждому участнику со своим ключом (docs/protocol.md, «Комнаты»).
|
||||
s.sendRoom(r, change, "")
|
||||
writeJSON(w, http.StatusOK, roomJSON(change.Room, keyFor(change, sess.Nick)))
|
||||
writeJSON(w, http.StatusOK, roomJSON(change.Room, keysFor(change, sess.Nick)))
|
||||
}
|
||||
|
||||
// POST /api/rooms/{id}/leave — выход из комнаты. Владение переходит
|
||||
@@ -270,7 +274,7 @@ func keysMismatch(w http.ResponseWriter) {
|
||||
// «События», ADR-041).
|
||||
func (s *server) sendRoom(r *http.Request, change store.RoomChange, exclude string) {
|
||||
for _, member := range change.Members {
|
||||
raw, err := json.Marshal(roomJSON(change.Room, member.Key))
|
||||
raw, err := json.Marshal(roomJSON(change.Room, keyList(member.Key)))
|
||||
if err != nil {
|
||||
s.report(r, err)
|
||||
continue
|
||||
@@ -302,31 +306,41 @@ func (s *server) send(devices []string, exclude string, ev hub.Event) {
|
||||
}
|
||||
}
|
||||
|
||||
// roomJSON собирает Room протокола: состав всегда список, ключ — null,
|
||||
// если его нет.
|
||||
func roomJSON(room store.Room, key *store.RoomKey) roomOut {
|
||||
// roomJSON собирает Room протокола: состав и ключи всегда списки,
|
||||
// пустые — [].
|
||||
func roomJSON(room store.Room, keys []store.RoomKey) roomOut {
|
||||
out := roomOut{
|
||||
ID: room.ID,
|
||||
Name: room.Name,
|
||||
Owner: room.Owner,
|
||||
Members: room.Members,
|
||||
CreatedAt: room.CreatedAt,
|
||||
Keys: make([]keyOut, 0, len(keys)),
|
||||
NeedsRekey: room.NeedsRekey,
|
||||
}
|
||||
if out.Members == nil {
|
||||
out.Members = []string{}
|
||||
}
|
||||
if key != nil {
|
||||
out.Key = &keyOut{KeyID: key.KeyID, From: key.From, IV: key.IV, CT: key.CT}
|
||||
for _, key := range keys {
|
||||
out.Keys = append(out.Keys, keyOut{KeyID: key.KeyID, From: key.From, IV: key.IV, CT: key.CT})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// keyFor — ключ участника в итоге изменения: у каждого он свой.
|
||||
func keyFor(change store.RoomChange, nick string) *store.RoomKey {
|
||||
// keyList — ключ события: он один, новый (ADR-059). Остальные свои ключи
|
||||
// получатель уже видел, а отключённый доберёт их из GET /api/rooms.
|
||||
func keyList(key *store.RoomKey) []store.RoomKey {
|
||||
if key == nil {
|
||||
return nil
|
||||
}
|
||||
return []store.RoomKey{*key}
|
||||
}
|
||||
|
||||
// keysFor — ключ участника в итоге изменения: у каждого он свой.
|
||||
func keysFor(change store.RoomChange, nick string) []store.RoomKey {
|
||||
for _, member := range change.Members {
|
||||
if member.Nick == nick {
|
||||
return member.Key
|
||||
return keyList(member.Key)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
|
||||
+90
-37
@@ -12,13 +12,22 @@ import (
|
||||
|
||||
// roomBody — тип Room из docs/protocol.md, как его видит клиент.
|
||||
type roomBody struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Owner string `json:"owner"`
|
||||
Members []string `json:"members"`
|
||||
CreatedAt int64 `json:"createdAt"`
|
||||
Key *keyBody `json:"key"`
|
||||
NeedsRekey bool `json:"needsRekey"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Owner string `json:"owner"`
|
||||
Members []string `json:"members"`
|
||||
CreatedAt int64 `json:"createdAt"`
|
||||
Keys []keyBody `json:"keys"`
|
||||
NeedsRekey bool `json:"needsRekey"`
|
||||
}
|
||||
|
||||
// key — текущий ключ: последний в keys, они идут от старого к новому
|
||||
// (ADR-059). nil — ключей нет вовсе.
|
||||
func (r roomBody) key() *keyBody {
|
||||
if len(r.Keys) == 0 {
|
||||
return nil
|
||||
}
|
||||
return &r.Keys[len(r.Keys)-1]
|
||||
}
|
||||
|
||||
type keyBody struct {
|
||||
@@ -151,9 +160,9 @@ func TestCreateRoom(t *testing.T) {
|
||||
if room.NeedsRekey {
|
||||
t.Error("needsRekey в ответе на создание")
|
||||
}
|
||||
if room.Key == nil || room.Key.KeyID != keyID(40) || room.Key.From != "marta" ||
|
||||
room.Key.IV != ivOf(40, 0) || room.Key.CT != ctOf(40, 0) {
|
||||
t.Errorf("ключ: %+v", room.Key)
|
||||
if room.key() == nil || room.key().KeyID != keyID(40) || room.key().From != "marta" ||
|
||||
room.key().IV != ivOf(40, 0) || room.key().CT != ctOf(40, 0) {
|
||||
t.Errorf("ключ: %+v", room.key())
|
||||
}
|
||||
|
||||
// Та же комната приходит списком, с тем же ключом.
|
||||
@@ -161,7 +170,7 @@ func TestCreateRoom(t *testing.T) {
|
||||
if len(list) != 1 {
|
||||
t.Fatalf("комнат: получено %d, ожидалась 1", len(list))
|
||||
}
|
||||
if list[0].ID != room.ID || list[0].Key == nil || list[0].Key.CT != ctOf(40, 0) {
|
||||
if list[0].ID != room.ID || list[0].key() == nil || list[0].key().CT != ctOf(40, 0) {
|
||||
t.Errorf("список комнат: %+v", list[0])
|
||||
}
|
||||
|
||||
@@ -206,7 +215,7 @@ func TestCreateRoomConflict(t *testing.T) {
|
||||
t.Errorf("занятый id присоединил к чужой комнате: %+v", got)
|
||||
}
|
||||
list := e.rooms(marta)
|
||||
if len(list) != 1 || list[0].Name != "общая" || list[0].Key == nil || list[0].Key.KeyID != keyID(40) {
|
||||
if len(list) != 1 || list[0].Name != "общая" || list[0].key() == nil || list[0].key().KeyID != keyID(40) {
|
||||
t.Errorf("занятый id тронул существующую комнату: %+v", list)
|
||||
}
|
||||
}
|
||||
@@ -301,8 +310,8 @@ func TestMembersAdd(t *testing.T) {
|
||||
if nicks(got.Members) != nicks(want) {
|
||||
t.Errorf("состав: получено %v, ожидалось %v", got.Members, want)
|
||||
}
|
||||
if got.Key == nil || got.Key.KeyID != keyID(60) || got.Key.CT != ctOf(60, 0) {
|
||||
t.Errorf("ключ владельца в ответе: %+v", got.Key)
|
||||
if got.key() == nil || got.key().KeyID != keyID(60) || got.key().CT != ctOf(60, 0) {
|
||||
t.Errorf("ключ владельца в ответе: %+v", got.key())
|
||||
}
|
||||
|
||||
// Каждый видит комнату со своим ключом.
|
||||
@@ -314,9 +323,9 @@ func TestMembersAdd(t *testing.T) {
|
||||
if list[0].Owner != "marta" || nicks(list[0].Members) != nicks(want) {
|
||||
t.Errorf("комната у %d: %+v", i, list[0])
|
||||
}
|
||||
if list[0].Key == nil || list[0].Key.KeyID != keyID(60) || list[0].Key.From != "marta" ||
|
||||
list[0].Key.CT != ctOf(60, i) {
|
||||
t.Errorf("ключ у %d: %+v", i, list[0].Key)
|
||||
if list[0].key() == nil || list[0].key().KeyID != keyID(60) || list[0].key().From != "marta" ||
|
||||
list[0].key().CT != ctOf(60, i) {
|
||||
t.Errorf("ключ у %d: %+v", i, list[0].key())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -384,8 +393,8 @@ func TestMembersKeysMismatch(t *testing.T) {
|
||||
if len(got.Members) != 1 || got.Members[0] != "marta" {
|
||||
t.Errorf("состав после отказов: %v", got.Members)
|
||||
}
|
||||
if got.Key == nil || got.Key.KeyID != keyID(40) {
|
||||
t.Errorf("ключ после отказов: %+v", got.Key)
|
||||
if got.key() == nil || got.key().KeyID != keyID(40) {
|
||||
t.Errorf("ключ после отказов: %+v", got.key())
|
||||
}
|
||||
if list := e.rooms(kolya); len(list) != 0 {
|
||||
t.Errorf("комната у постороннего: %+v", list)
|
||||
@@ -533,8 +542,8 @@ func TestLeaveTransfersOwnership(t *testing.T) {
|
||||
t.Errorf("состав: %v", got.Members)
|
||||
}
|
||||
// Ключ оставшихся никуда не делся: rekey делает клиент нового владельца.
|
||||
if got.Key == nil || got.Key.KeyID != keyID(80) {
|
||||
t.Errorf("ключ после выхода владельца: %+v", got.Key)
|
||||
if got.key() == nil || got.key().KeyID != keyID(80) {
|
||||
t.Errorf("ключ после выхода владельца: %+v", got.key())
|
||||
}
|
||||
// Новый владелец меняет состав, прежний — уже нет.
|
||||
expect(t, e.changeMembers(kolya, room.ID, nil, nil, []string{"petya", "kolya"}, 100),
|
||||
@@ -690,8 +699,8 @@ func TestRoomKeysKeepTwo(t *testing.T) {
|
||||
with(marta), withDevice(m1)), http.StatusAccepted, "")
|
||||
}
|
||||
// Текущий ключ участника — последний.
|
||||
if got := e.room(marta, room.ID); got.Key == nil || got.Key.KeyID != keyID(80) {
|
||||
t.Errorf("текущий ключ: %+v", got.Key)
|
||||
if got := e.room(marta, room.ID); got.key() == nil || got.key().KeyID != keyID(80) {
|
||||
t.Errorf("текущий ключ: %+v", got.key())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -722,8 +731,8 @@ func TestDeleteAccountWithRooms(t *testing.T) {
|
||||
if len(list[0].Members) != 1 || list[0].Members[0] != "petya" {
|
||||
t.Errorf("состав: %v", list[0].Members)
|
||||
}
|
||||
if list[0].Key == nil || list[0].Key.KeyID != keyID(60) {
|
||||
t.Errorf("ключ оставшегося: %+v", list[0].Key)
|
||||
if list[0].key() == nil || list[0].key().KeyID != keyID(60) {
|
||||
t.Errorf("ключ оставшегося: %+v", list[0].key())
|
||||
}
|
||||
// Ник свободен, а комната, где не осталось никого, исчезла вместе с ним.
|
||||
expect(t, e.do(http.MethodPost, "/api/register", account("marta")), http.StatusCreated, "")
|
||||
@@ -759,8 +768,8 @@ func TestRoomEventOnCreate(t *testing.T) {
|
||||
if got.ID != room.ID || got.Name != "общая" || got.Owner != "marta" {
|
||||
t.Errorf("комната в событии: %+v", got)
|
||||
}
|
||||
if got.Key == nil || got.Key.CT != ctOf(40, 0) {
|
||||
t.Errorf("ключ в событии: %+v", got.Key)
|
||||
if got.key() == nil || got.key().CT != ctOf(40, 0) {
|
||||
t.Errorf("ключ в событии: %+v", got.key())
|
||||
}
|
||||
if got.NeedsRekey {
|
||||
t.Error("needsRekey при создании")
|
||||
@@ -808,8 +817,8 @@ func TestRoomEventsOnMembers(t *testing.T) {
|
||||
if nicks(got.Members) != "marta,petya" {
|
||||
t.Errorf("состав в событии у %s: %v", nick, got.Members)
|
||||
}
|
||||
if got.Key == nil || got.Key.KeyID != keyID(80) || got.Key.CT != ctOf(80, i) {
|
||||
t.Errorf("ключ в событии у %s: %+v", nick, got.Key)
|
||||
if got.key() == nil || got.key().KeyID != keyID(80) || got.key().CT != ctOf(80, i) {
|
||||
t.Errorf("ключ в событии у %s: %+v", nick, got.key())
|
||||
}
|
||||
if got.NeedsRekey {
|
||||
t.Errorf("needsRekey при смене состава у %s", nick)
|
||||
@@ -853,8 +862,8 @@ func TestRoomEventOnLeave(t *testing.T) {
|
||||
if len(got.Members) != 1 || got.Members[0] != "marta" {
|
||||
t.Errorf("состав в событии: %v", got.Members)
|
||||
}
|
||||
if got.Key == nil || got.Key.KeyID != keyID(60) {
|
||||
t.Errorf("ключ в событии: %+v", got.Key)
|
||||
if got.key() == nil || got.key().KeyID != keyID(60) {
|
||||
t.Errorf("ключ в событии: %+v", got.key())
|
||||
}
|
||||
// Другим устройствам вышедшего — room_left: комната ушла из списка,
|
||||
// и ждать следующего ready им незачем (ADR-041). Запрос шёл без
|
||||
@@ -959,8 +968,8 @@ func TestNeedsRekeyOutlivesEvent(t *testing.T) {
|
||||
if got == nil || !got.NeedsRekey {
|
||||
t.Fatalf("needsRekey в списке комнат: %+v", got)
|
||||
}
|
||||
if got.Key == nil || got.Key.KeyID != keyID(60) {
|
||||
t.Errorf("ключ в списке: %+v", got.Key)
|
||||
if got.key() == nil || got.key().KeyID != keyID(60) {
|
||||
t.Errorf("ключ в списке: %+v", got.key())
|
||||
}
|
||||
|
||||
// Rekey закрывает долг.
|
||||
@@ -970,6 +979,50 @@ func TestNeedsRekeyOutlivesEvent(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Участник, пропустивший два rekey в офлайне, получает оба удерживаемых
|
||||
// ключа: без прежнего он не прочитал бы конверт, который лежит в его
|
||||
// очереди с промежуточным keyId (ADR-059).
|
||||
func TestRoomKeysCoverMissedRekey(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
marta, m1 := e.join("marta", 1)
|
||||
petya, p1 := e.join("petya", 2)
|
||||
e.join("kolya", 3)
|
||||
|
||||
room := e.makeRoom(marta, "marta", "общая", 40)
|
||||
// Первый rekey: пришёл kolya. Устройство petya офлайн — событие room
|
||||
// в очередь не кладётся, и ключ до него не доехал.
|
||||
expect(t, e.changeMembers(marta, room.ID, []string{"petya", "kolya"}, nil,
|
||||
[]string{"marta", "petya", "kolya"}, 60), http.StatusOK, "")
|
||||
|
||||
id := ulid(nowMillis(), 5)
|
||||
expect(t, e.do(http.MethodPost, "/api/messages", roomMessage(id, room.ID, keyID(60)),
|
||||
with(marta), withDevice(m1)), http.StatusAccepted, "")
|
||||
|
||||
// Второй rekey: kolya ушёл. Текущим стал третий ключ.
|
||||
expect(t, e.changeMembers(marta, room.ID, nil, []string{"kolya"}, []string{"marta", "petya"}, 80),
|
||||
http.StatusOK, "")
|
||||
|
||||
queued := e.envelopes(p1)
|
||||
if len(queued) != 1 || queued[0].KeyID != keyID(60) {
|
||||
t.Fatalf("очередь petya: %+v", queued)
|
||||
}
|
||||
|
||||
got := e.room(petya, room.ID)
|
||||
if got == nil || len(got.Keys) != 2 {
|
||||
t.Fatalf("ключи petya: %+v", got)
|
||||
}
|
||||
if got.Keys[0].KeyID != keyID(60) || got.Keys[0].CT != ctOf(60, 1) {
|
||||
t.Errorf("пропущенный ключ: %+v", got.Keys[0])
|
||||
}
|
||||
if got.Keys[1].KeyID != keyID(80) || got.Keys[1].CT != ctOf(80, 1) {
|
||||
t.Errorf("текущий ключ: %+v", got.Keys[1])
|
||||
}
|
||||
// Ключ конверта из очереди теперь у него есть.
|
||||
if got.Keys[0].KeyID != queued[0].KeyID {
|
||||
t.Errorf("ключа конверта нет среди выданных: %+v", got.Keys)
|
||||
}
|
||||
}
|
||||
|
||||
// Удаление аккаунта — выход из всех его комнат: оставшимся уходит room
|
||||
// с needsRekey и их собственным ключом, владение переходит (ADR-041).
|
||||
func TestDeleteAccountLeavesRooms(t *testing.T) {
|
||||
@@ -1012,8 +1065,8 @@ func TestDeleteAccountLeavesRooms(t *testing.T) {
|
||||
}
|
||||
// Каждому — его собственный ключ: он различается порядковым
|
||||
// номером внутри «шифротекста».
|
||||
if got.Key == nil || got.Key.CT != ctOf(60, i+1) {
|
||||
t.Errorf("ключ в событии: %+v", got.Key)
|
||||
if got.key() == nil || got.key().CT != ctOf(60, i+1) {
|
||||
t.Errorf("ключ в событии: %+v", got.key())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1042,7 +1095,7 @@ func TestMembersDuplicateKeyTarget(t *testing.T) {
|
||||
rec := e.changeMembers(marta, room.ID, nil, nil, []string{"marta", "marta"}, 80)
|
||||
expect(t, rec, http.StatusBadRequest, "keys_mismatch")
|
||||
// Отказ ничего не изменил: ключ комнаты прежний.
|
||||
if got := e.room(marta, room.ID); got.Key == nil || got.Key.KeyID != keyID(60) {
|
||||
t.Errorf("ключ после keys_mismatch: %+v", got.Key)
|
||||
if got := e.room(marta, room.ID); got.key() == nil || got.key().KeyID != keyID(60) {
|
||||
t.Errorf("ключ после keys_mismatch: %+v", got.key())
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user