Этап 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)
}
+12 -2
View File
@@ -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
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). Этот маршрут заодно не даёт
+31 -1
View File
@@ -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 == "" {
+39
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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", "слишком часто, попробуйте позже")
}
+36
View File
@@ -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("остановка не закончилась после отправки")
}
}
+305
View File
@@ -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
View File
@@ -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
View File
@@ -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())
}
}
+20 -13
View File
@@ -49,16 +49,18 @@ type WrappedKey struct {
CT string
}
// Room — комната и её состав. Key — текущий ключ того, кто спрашивает;
// nil означает, что ключа у него нет. NeedsRekey — состав уменьшился,
// а нового ключа ещё не было (ADR-041).
// Room — комната и её состав. Keysзавёрнутые ключи того, кто
// спрашивает, от старого к новому: сервер держит два последних keyId
// (ADR-018) и отдаёт участнику все, иначе пропущенный в офлайне ключ
// не добыть ничем (ADR-059). Пусто — ключей у него нет. NeedsRekey —
// состав уменьшился, а нового ключа ещё не было (ADR-041).
type Room struct {
ID string
Name string
Owner string
Members []string // по joined_at
CreatedAt int64
Key *RoomKey
Keys []RoomKey
NeedsRekey bool
}
@@ -71,15 +73,17 @@ type Recipient struct {
}
// RoomChange — итог изменения комнаты: кому уходит room, а кому room_left.
// Room.Key всегда nil — ключ у каждого получателя свой, он в Recipient.
// Room.Keys всегда пусты — ключ у каждого получателя свой, он в Recipient.
type RoomChange struct {
Room Room
Members []Recipient // итоговый состав
Left []Recipient // выбывшие
}
// Rooms — комнаты, где пользователь участник, каждая с его текущим
// ключом (docs/protocol.md, «Комнаты»).
// Rooms — комнаты, где пользователь участник, каждая со всеми его
// завёрнутыми ключами: сервер держит два последних keyId, и участник,
// пропустивший rekey в офлайне, добирает пропущенный отсюда
// (ADR-059, docs/protocol.md, «Комнаты»).
func (s *Store) Rooms(ctx context.Context, nick string) ([]Room, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT r.id, r.name, r.owner, r.created_at, r.needs_rekey
@@ -130,22 +134,25 @@ func (s *Store) Rooms(ctx context.Context, nick string) ([]Room, error) {
return nil, fmt.Errorf("store: состав комнат: %w", err)
}
keys, err := s.db.QueryContext(ctx, currentKeysQuery+` AND nick = ?`, nick)
// Порядок — от старого ключа к новому, тот же, что у обрезки
// и у «текущего» (ADR-042): последний в списке и есть текущий.
keys, err := s.db.QueryContext(ctx, `
SELECT room_id, key_id, sender, iv, ct FROM room_keys
WHERE nick = ? AND room_id IN (SELECT room_id FROM room_members WHERE nick = ?)
ORDER BY room_id, created_at, key_id`, nick, nick)
if err != nil {
return nil, fmt.Errorf("store: ключи комнат: %w", err)
}
defer keys.Close()
for keys.Next() {
// Второй столбец — ник владельца ключа, здесь он всегда nick.
var room, member string
var room string
var k RoomKey
if err := keys.Scan(&room, &member, &k.KeyID, &k.From, &k.IV, &k.CT); err != nil {
if err := keys.Scan(&room, &k.KeyID, &k.From, &k.IV, &k.CT); err != nil {
return nil, fmt.Errorf("store: ключи комнат: %w", err)
}
if i, ok := at[room]; ok {
key := k
out[i].Key = &key
out[i].Keys = append(out[i].Keys, k)
}
}
if err := keys.Err(); err != nil {
+34 -7
View File
@@ -88,7 +88,7 @@ func TestCreateRoomTakenID(t *testing.T) {
t.Fatalf("Rooms: %v", err)
}
if len(rooms) != 1 || rooms[0].Name != "общая" || rooms[0].Owner != "marta" ||
rooms[0].Key == nil || rooms[0].Key.KeyID != "k1" {
current(rooms[0]) == nil || current(rooms[0]).KeyID != "k1" {
t.Errorf("комната после отказа: %+v", rooms)
}
if got, err := s.Rooms(ctx, "petya"); err != nil || len(got) != 0 {
@@ -96,6 +96,15 @@ func TestCreateRoomTakenID(t *testing.T) {
}
}
// current — текущий ключ участника: последний в Keys, они идут от старого
// к новому (ADR-059).
func current(r Room) *RoomKey {
if len(r.Keys) == 0 {
return nil
}
return &r.Keys[len(r.Keys)-1]
}
// У комнаты живут два последних keyId; обрезка при rekey и фоновая чистка
// держат одни и те же ключи и не трогают текущий ключ участника (ADR-018).
func TestRoomKeysTrimmedToTwo(t *testing.T) {
@@ -120,13 +129,31 @@ func TestRoomKeysTrimmedToTwo(t *testing.T) {
if err != nil {
t.Fatalf("Rooms %s: %v", nick, err)
}
if len(rooms) != 1 || rooms[0].Key == nil {
if len(rooms) != 1 || current(rooms[0]) == nil {
t.Fatalf("комнаты %s: %+v", nick, rooms)
}
if rooms[0].Key.KeyID != "k3" || rooms[0].Key.CT != "ct-"+nick {
t.Errorf("ключ %s: %+v", nick, rooms[0].Key)
if current(rooms[0]).KeyID != "k3" || current(rooms[0]).CT != "ct-"+nick {
t.Errorf("ключ %s: %+v", nick, current(rooms[0]))
}
}
// Участнику отдаются оба удерживаемых ключа, от старого к новому:
// без прежнего он не прочитает сообщение, отправленное до последнего
// rekey, пока его не было (ADR-059).
rooms, err := s.Rooms(ctx, "petya")
if err != nil {
t.Fatalf("Rooms petya: %v", err)
}
if len(rooms) != 1 || len(rooms[0].Keys) != 2 {
t.Fatalf("ключи petya: %+v", rooms)
}
if rooms[0].Keys[0].KeyID != "k2" || rooms[0].Keys[1].KeyID != "k3" {
t.Errorf("порядок ключей: получено %v, ожидалось [k2 k3]",
[]string{rooms[0].Keys[0].KeyID, rooms[0].Keys[1].KeyID})
}
// Ключей чужой комнаты в ответе нет.
if got, err := s.Rooms(ctx, "marta"); err != nil || len(got) != 1 || len(got[0].Keys) != 2 {
t.Errorf("ключи marta: %+v, %v", got, err)
}
}
// Два rekey в одну миллисекунду: текущим остаётся последний розданный ключ,
@@ -147,11 +174,11 @@ func TestRoomKeysWithinOneMillisecond(t *testing.T) {
}
for _, nick := range []string{"marta", "petya"} {
rooms, err := s.Rooms(ctx, nick)
if err != nil || len(rooms) != 1 || rooms[0].Key == nil {
if err != nil || len(rooms) != 1 || current(rooms[0]) == nil {
t.Fatalf("комнаты %s: %+v, %v", nick, rooms, err)
}
if rooms[0].Key.KeyID != "aaa" {
t.Errorf("текущий ключ %s: получено %q, ожидалось \"aaa\"", nick, rooms[0].Key.KeyID)
if current(rooms[0]).KeyID != "aaa" {
t.Errorf("текущий ключ %s: получено %q, ожидалось \"aaa\"", nick, current(rooms[0]).KeyID)
}
}
// Свежим ключом можно писать: он остался ключом комнаты.
+14 -2
View File
@@ -105,11 +105,23 @@ func TestUsersAndSessions(t *testing.T) {
t.Errorf("истёкшая сессия: получено %v, ожидалось ErrNotFound", err)
}
// Смена пароля с logoutOthers: остаётся только текущая сессия.
// Смена пароля с logoutOthers: остаётся только текущая сессия, а
// устройства завершённых сессий отдаются обработчику — он закроет
// их потоки событий (ADR-058).
if _, err := s.RegisterDevice(ctx, "device-live", "marta", live, now); err != nil {
t.Fatalf("RegisterDevice: %v", err)
}
if _, err := s.RegisterDevice(ctx, "device-other", "marta", other, now); err != nil {
t.Fatalf("RegisterDevice: %v", err)
}
cred := Credential{Hash: []byte("new"), Salt: []byte("salt2"), Params: "argon2id,m=19456,t=2,p=1"}
if err := s.SetPassword(ctx, "marta", cred, `{"v":1,"new":true}`, true, live); err != nil {
revoked, err := s.SetPassword(ctx, "marta", cred, `{"v":1,"new":true}`, true, live)
if err != nil {
t.Fatalf("SetPassword: %v", err)
}
if len(revoked) != 1 || revoked[0] != "device-other" {
t.Errorf("устройства завершённых сессий: получено %v, ожидалось [device-other]", revoked)
}
if _, err := s.Session(ctx, other, now); !errors.Is(err, ErrNotFound) {
t.Errorf("чужая сессия после logoutOthers: получено %v, ожидалось ErrNotFound", err)
}
+43 -6
View File
@@ -80,28 +80,65 @@ func (s *Store) SetAuth(ctx context.Context, nick string, cred Credential) error
// разъехавшиеся хеш и блоб означали бы аккаунт, в который нельзя войти
// или ключ которого не расшифровать. При logoutOthers в той же транзакции
// удаляются все сессии пользователя, кроме keep — текущей.
func (s *Store) SetPassword(ctx context.Context, nick string, cred Credential, blob string, logoutOthers bool, keep []byte) error {
//
// Первое значение — устройства, к которым были привязаны удалённые сессии:
// их потоки событий закрывает обработчик. Поток проверяет сессию только
// при подключении, поэтому отозванная иначе продолжала бы получать
// конверты до обрыва соединения (ADR-058).
func (s *Store) SetPassword(ctx context.Context, nick string, cred Credential, blob string, logoutOthers bool, keep []byte) ([]string, error) {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("store: смена пароля: %w", err)
return nil, fmt.Errorf("store: смена пароля: %w", err)
}
defer tx.Rollback()
if _, err := tx.ExecContext(ctx, `
UPDATE users SET auth_hash = ?, auth_salt = ?, auth_params = ?, key_blob = ? WHERE nick = ?`,
cred.Hash, cred.Salt, cred.Params, blob, nick); err != nil {
return fmt.Errorf("store: смена пароля: %w", err)
return nil, fmt.Errorf("store: смена пароля: %w", err)
}
var revoked []string
if logoutOthers {
revoked, err = revokedDevices(ctx, tx, nick, keep)
if err != nil {
return nil, err
}
if _, err := tx.ExecContext(ctx, `
DELETE FROM sessions WHERE nick = ? AND token_hash <> ?`, nick, keep); err != nil {
return fmt.Errorf("store: смена пароля: %w", err)
return nil, fmt.Errorf("store: смена пароля: %w", err)
}
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("store: смена пароля: %w", err)
return nil, fmt.Errorf("store: смена пароля: %w", err)
}
return nil
return revoked, nil
}
// revokedDevices — устройства завершаемых сессий, кроме устройства текущей:
// её оставляют, и закрывать её поток незачем.
func revokedDevices(ctx context.Context, tx *sql.Tx, nick string, keep []byte) ([]string, error) {
rows, err := tx.QueryContext(ctx, `
SELECT DISTINCT device_id FROM sessions
WHERE nick = ? AND token_hash <> ? AND device_id IS NOT NULL
AND device_id NOT IN (SELECT device_id FROM sessions WHERE token_hash = ? AND device_id IS NOT NULL)
ORDER BY device_id`, nick, keep, keep)
if err != nil {
return nil, fmt.Errorf("store: устройства завершённых сессий: %w", err)
}
defer rows.Close()
var out []string
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
return nil, fmt.Errorf("store: устройства завершённых сессий: %w", err)
}
out = append(out, id)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("store: устройства завершённых сессий: %w", err)
}
return out, nil
}
// DeleteUser удаляет пользователя; устройства, сессии, контакты, членство,