Files
bare/internal/api/messages.go
T
mayatnikovandClaude Opus 5 db45978b16 Этап 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
2026-08-23 06:22:52 +03:00

247 lines
8.2 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package api
import (
"encoding/json"
"net/http"
"time"
"github.com/xmatic-squad/bare/internal/auth"
"github.com/xmatic-squad/bare/internal/hub"
"github.com/xmatic-squad/bare/internal/push"
"github.com/xmatic-squad/bare/internal/store"
)
// clockSkew — на сколько метка времени ULID вправе разойтись с часами
// сервера (ADR-017).
const clockSkew = 5 * time.Minute
// dmKeyID — keyId личного чата: ключ выводится из ECDH, идентификатора
// у него нет (docs/crypto.md, «Сообщение»).
const dmKeyID = "dm"
// maxAck — сколько идентификаторов принимает один ACK.
const maxAck = 500
// target — адресат конверта: ровно одно из двух.
type target struct {
DM string `json:"dm,omitempty"`
Room string `json:"room,omitempty"`
}
// envelope — конверт из docs/protocol.md. Порядок полей — как в нём.
// from и ts ставит сервер: клиентские значения не читаются вовсе (ADR-017).
type envelope struct {
ID string `json:"id"`
To target `json:"to"`
From string `json:"from"`
KeyID string `json:"keyId"`
IV string `json:"iv"`
CT string `json:"ct"`
TS int64 `json:"ts"`
}
// messageIn — тело POST /api/messages. Полей from и ts здесь нет
// намеренно: что бы клиент ни прислал, сервер ставит своё (ADR-017).
type messageIn struct {
ID string `json:"id"`
To target `json:"to"`
KeyID string `json:"keyId"`
IV string `json:"iv"`
CT string `json:"ct"`
}
// POST /api/messages — отправка. Сервер не умеет проверять шифротекст,
// он проверяет форму и раскладывает конверт по очередям (ADR-008).
// Порядок проверок — docs/protocol.md, «Сообщения».
func (s *server) sendMessage(w http.ResponseWriter, r *http.Request) {
var in messageIn
if !decode(w, r, &in) {
return
}
ms, ok := checkForm(w, in)
if !ok {
return
}
now := time.Now()
if d := now.Sub(time.UnixMilli(ms)); d > clockSkew || d < -clockSkew {
Error(w, http.StatusBadRequest, "clock_skew",
"проверьте часы на устройстве: расхождение больше 5 минут")
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 != ""
// Заголовок и адрес чата для пуша: сервер собирает их из того, что
// и так знает, — из ников и имени комнаты (ADR-023).
var signal push.Payload
if room {
access, err := s.st.RoomAccess(r.Context(), in.To.Room, sess.Nick, in.KeyID)
if err != nil {
s.internal(w, r, err)
return
}
if !access.Member {
Error(w, http.StatusForbidden, "not_member", "вы не участник комнаты")
return
}
if !access.KnownKey {
Error(w, http.StatusBadRequest, "unknown_key", "у комнаты нет такого ключа")
return
}
signal = push.Payload{Title: "#" + access.Name, Chat: "room:" + in.To.Room}
} else {
if _, ok := s.peer(w, r, in.To.DM, sess.Nick); !ok {
return
}
signal = push.Payload{Title: "@" + sess.Nick, Chat: "dm:" + sess.Nick}
}
if wait, ok := s.msgs.take(sess.Nick, now); !ok {
s.rateLimited(w, wait)
return
}
env := envelope{
ID: in.ID,
To: target{DM: in.To.DM, Room: in.To.Room},
From: sess.Nick,
KeyID: in.KeyID,
IV: in.IV,
CT: in.CT,
TS: now.UnixMilli(),
}
raw, err := json.Marshal(env)
if err != nil {
s.internal(w, r, err)
return
}
delivery := store.Delivery{
From: env.From,
To: env.To.DM,
Room: env.To.Room,
Exclude: device,
MsgID: env.ID,
Envelope: string(raw),
Now: env.TS,
}
var devices []store.Target
if room {
devices, err = s.st.DeliverRoom(r.Context(), delivery)
} else {
devices, err = s.st.DeliverDM(r.Context(), delivery)
}
if err != nil {
s.internal(w, r, err)
return
}
// Очередь уже записана: подключённое устройство получает конверт
// сразу, остальные — при подключении.
for _, target := range devices {
s.hub.Send(target.ID, hub.Event{Name: "msg", Data: string(raw)})
}
// Пуш — побочный эффект доставки, а не её часть: конверт уже
// в очереди, и ответ на запрос отправку пуша не ждёт (ADR-023).
s.push.Send(s.silent(devices, env.From), signal)
writeJSON(w, http.StatusAccepted, struct {
ID string `json:"id"`
TS int64 `json:"ts"`
}{env.ID, env.TS})
}
// silent — устройства, которым нужен пуш: чужие (устройства отправителя
// пуша не получают, ADR-045), подписанные и молчащие — те, что не держат
// поток событий (ADR-023).
//
// Устройство без подписки отсеивается здесь: отправить ему нечего,
// а место в очереди отправки оно заняло бы (ADR-048). Проверка на
// подключение — ранний отсев: решает её повтор в момент захвата права
// на пуш, потому что между этой строкой и отправкой устройство успевает
// подключиться (ADR-023).
func (s *server) silent(targets []store.Target, from string) []push.Target {
var out []push.Target
for _, target := range targets {
if target.Nick == from || !target.HasPush || s.hub.Connected(target.ID) {
continue
}
out = append(out, push.Target{Device: target.ID, Owner: target.Nick})
}
return out
}
// checkForm проверяет форму полей конверта (docs/crypto.md, «Что сервер
// проверяет») и отдаёт метку времени из ULID. Ответ об ошибке уже написан,
// если вернулось false.
func checkForm(w http.ResponseWriter, in messageIn) (int64, bool) {
ms, ok := ulidTime(in.ID)
if !ok {
Invalid(w, "id", "id — не ulid из 26 символов")
return 0, false
}
if (in.To.DM == "") == (in.To.Room == "") {
Invalid(w, "to", "to — ровно одно из dm и room")
return 0, false
}
if in.To.DM != "" {
if !validNick(in.To.DM) {
Invalid(w, "to", "ник: 232 символа, az, 09, _")
return 0, false
}
if in.KeyID != dmKeyID {
Invalid(w, "keyId", `keyId личного чата — "dm"`)
return 0, false
}
} else {
if !validID(in.To.Room) {
Invalid(w, "to", "room — не 16 байт base64url")
return 0, false
}
if !validID(in.KeyID) {
Invalid(w, "keyId", "keyId — не 16 байт base64url")
return 0, false
}
}
if _, ok := decodeExactly(in.IV, ivLen); !ok {
Invalid(w, "iv", "iv — не 12 байт base64url")
return 0, false
}
if ct, err := b64.DecodeString(in.CT); err != nil || len(ct) < minCTLen {
Invalid(w, "ct", "ct — не base64url или слишком короткий")
return 0, false
}
return ms, true
}
// POST /api/ack — клиент записал сообщения в IndexedDB: из очереди
// устройства их можно убрать (ADR-008).
func (s *server) ack(w http.ResponseWriter, r *http.Request) {
var in struct {
IDs []string `json:"ids"`
}
if !decode(w, r, &in) {
return
}
if len(in.IDs) > maxAck {
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)
}