Files
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

426 lines
16 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"
"errors"
"net/http"
"time"
"unicode"
"unicode/utf8"
"github.com/xmatic-squad/bare/internal/auth"
"github.com/xmatic-squad/bare/internal/hub"
"github.com/xmatic-squad/bare/internal/store"
)
// Комнаты (ADR-018): владелец меняет состав, ключи заворачивают клиенты.
// Сервер проверяет форму и права, хранит шифротекст и раздаёт события.
// maxRoomName — имя комнаты, символов (ADR-021). Имя открыто: это
// метаданные, как и состав.
const maxRoomName = 64
// roomOut — тип Room из docs/protocol.md. keys присутствует всегда,
// пустой — []; в GET /api/rooms это все удерживаемые сервером ключи
// запрашивающего, от старого к новому, в событии room — только новый
// (ADR-059). needsRekey — состояние комнаты, а не свойство события,
// поэтому идёт и в списке, и в событии (ADR-041).
type roomOut struct {
ID string `json:"id"`
Name string `json:"name"`
Owner string `json:"owner"`
Members []string `json:"members"`
CreatedAt int64 `json:"createdAt"`
Keys []keyOut `json:"keys"`
NeedsRekey bool `json:"needsRekey"`
}
// keyOut — завёрнутый ключ комнаты для того, кто его получает.
type keyOut struct {
KeyID string `json:"keyId"`
From string `json:"from"`
IV string `json:"iv"`
CT string `json:"ct"`
}
// keyIn — запись keys[] запроса: WrappedKey из docs/protocol.md.
type keyIn struct {
To string `json:"to"`
IV string `json:"iv"`
CT string `json:"ct"`
}
// GET /api/rooms — комнаты, где пользователь участник, каждая с его
// ключами и признаком 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)
if err != nil {
s.internal(w, r, err)
return
}
out := make([]roomOut, 0, len(list))
for _, room := range list {
out = append(out, roomJSON(room, room.Keys))
}
writeJSON(w, http.StatusOK, out)
}
// POST /api/rooms — создание комнаты. Идентификатор выдаёт клиент
// (ADR-037), ключ приходит ровно один и заворачивается создателем себе:
// его другие устройства получают комнату вместе с ключом (ADR-018).
func (s *server) createRoom(w http.ResponseWriter, r *http.Request) {
var in struct {
ID string `json:"id"`
Name string `json:"name"`
KeyID string `json:"keyId"`
Keys []keyIn `json:"keys"`
}
if !decode(w, r, &in) {
return
}
// Идентификатор комнаты генерирует клиент: ключ заворачивается до
// запроса и привязан к roomId в info и AAD (ADR-037).
if !validID(in.ID) {
Invalid(w, "id", "id комнаты — не 16 байт base64url")
return
}
if !validRoomName(in.Name) {
Invalid(w, "name", "имя комнаты: 1–64 символа")
return
}
if !validID(in.KeyID) {
Invalid(w, "keyId", "keyId — не 16 байт base64url")
return
}
keys, ok := wrappedKeys(w, in.Keys)
if !ok {
return
}
sess, _ := auth.From(r)
// Состав новой комнаты — один создатель, поэтому и ключ ровно один.
// Несовпадение — то же самое, что при rekey: keys не по составу.
if len(keys) != 1 || keys[0].To != sess.Nick {
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,
Owner: sess.Nick,
KeyID: in.KeyID,
Key: keys[0],
Now: time.Now().UnixMilli(),
})
if errors.Is(err, store.ErrRoomExists) {
// Занятый идентификатор не присоединяет к чужой комнате и не
// перезаписывает свою: клиент берёт новый (ADR-037).
Error(w, http.StatusConflict, "room_conflict", "такая комната уже есть")
return
}
if err != nil {
s.internal(w, r, err)
return
}
// Комната уже записана: остальным устройствам создателя она уходит
// событием, отправившему — ответом на запрос.
s.sendRoom(r, change, device)
writeJSON(w, http.StatusCreated, roomJSON(change.Room, keysFor(change, sess.Nick)))
}
// POST /api/rooms/{id}/members — смена состава и rekey одним запросом
// (ADR-018). Пустые add и remove — чистый rekey.
func (s *server) updateMembers(w http.ResponseWriter, r *http.Request) {
var in struct {
Add []string `json:"add"`
Remove []string `json:"remove"`
KeyID string `json:"keyId"`
Keys []keyIn `json:"keys"`
}
if !decode(w, r, &in) {
return
}
add, ok := uniqueNicks(in.Add)
if !ok {
// Форма — это форма: несуществующий ник верной формы отвечает
// unknown_user, а ник не по форме — invalid, как и в remove
// (ADR-043).
Invalid(w, "add", "добавить можно только ник a–z, 0–9, _")
return
}
remove, ok := uniqueNicks(in.Remove)
if !ok {
Invalid(w, "remove", "убрать можно только участника комнаты")
return
}
for _, nick := range remove {
for _, other := range add {
if nick == other {
Invalid(w, "remove", "один ник нельзя добавить и убрать одним запросом")
return
}
}
}
if !validID(in.KeyID) {
Invalid(w, "keyId", "keyId — не 16 байт base64url")
return
}
keys, ok := wrappedKeys(w, in.Keys)
if !ok {
return
}
sess, _ := auth.From(r)
change, err := s.st.UpdateMembers(r.Context(), store.MembersChange{
RoomID: r.PathValue("id"),
Owner: sess.Nick,
Add: add,
Remove: remove,
KeyID: in.KeyID,
Keys: keys,
Now: time.Now().UnixMilli(),
})
if err != nil {
s.roomError(w, r, err)
return
}
// Событие room уходит и участникам, и — как room_left — убранным;
// каждому участнику со своим ключом (docs/protocol.md, «Комнаты»).
s.sendRoom(r, change, "")
writeJSON(w, http.StatusOK, roomJSON(change.Room, keysFor(change, sess.Nick)))
}
// POST /api/rooms/{id}/leave — выход из комнаты. Владение переходит
// участнику с наименьшим joined_at, опустевшая комната удаляется;
// оставшимся уходит room с needsRekey (ADR-018), другим устройствам
// вышедшего — room_left (ADR-041).
//
// Не участник и несуществующая комната отвечают тем же 204: выходить
// неоткуда, а отдельного кода на этот случай в протоколе нет.
func (s *server) leaveRoom(w http.ResponseWriter, r *http.Request) {
// Заголовок необязателен, но чужой и кривой — 403, как и везде,
// где устройство важно (docs/protocol.md, «Общие правила»).
device, ok := s.optionalDevice(w, r)
if !ok {
return
}
sess, _ := auth.From(r)
change, err := s.st.LeaveRoom(r.Context(), r.PathValue("id"), sess.Nick)
if errors.Is(err, store.ErrNotFound) {
noContent(w)
return
}
if err != nil {
s.internal(w, r, err)
return
}
s.sendRoom(r, change, device)
noContent(w)
}
// DELETE /api/rooms/{id} — удаление комнаты владельцем. Всем участникам,
// включая его самого, уходит room_left.
func (s *server) deleteRoom(w http.ResponseWriter, r *http.Request) {
sess, _ := auth.From(r)
change, err := s.st.DeleteRoom(r.Context(), r.PathValue("id"), sess.Nick)
if err != nil {
s.roomError(w, r, err)
return
}
s.sendRoom(r, change, "")
noContent(w)
}
// roomError переводит отказы хранилища в коды протокола
// (docs/protocol.md, «Комнаты»).
func (s *server) roomError(w http.ResponseWriter, r *http.Request, err error) {
switch {
case errors.Is(err, store.ErrNotOwner):
Error(w, http.StatusForbidden, "not_owner", "комнату меняет её владелец")
case errors.Is(err, store.ErrUnknownUser):
unknownUser(w)
case errors.Is(err, store.ErrNotMember):
Invalid(w, "remove", "убрать можно только участника комнаты")
case errors.Is(err, store.ErrOwnerRemoval):
Error(w, http.StatusBadRequest, "owner", "владельца убрать нельзя")
case errors.Is(err, store.ErrKeyExists):
Error(w, http.StatusConflict, "key_exists", "такой ключ у комнаты уже был")
case errors.Is(err, store.ErrKeysMismatch):
keysMismatch(w)
default:
s.internal(w, r, err)
}
}
func keysMismatch(w http.ResponseWriter) {
Error(w, http.StatusBadRequest, "keys_mismatch", "ключи не совпадают с составом комнаты")
}
// sendRoom раздаёт события изменившейся комнаты: room участникам, каждому
// с его собственным ключом, и room_left выбывшим. exclude — устройство,
// которому событие не нужно; пусто — нужно всем.
//
// Событие в очередь не кладётся: клиент после каждого ready перечитывает
// GET /api/rooms, а всё, что несёт room, включая needsRekey, есть и там,
// поэтому пропуск во время офлайна ничего не ломает (docs/protocol.md,
// «События», 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, keyList(member.Key)))
if err != nil {
s.report(r, err)
continue
}
s.send(member.Devices, exclude, hub.Event{Name: "room", Data: string(raw)})
}
if len(change.Left) == 0 {
return
}
raw, err := json.Marshal(struct {
ID string `json:"id"`
}{change.Room.ID})
if err != nil {
s.report(r, err)
return
}
for _, gone := range change.Left {
s.send(gone.Devices, exclude, hub.Event{Name: "room_left", Data: string(raw)})
}
}
// send отдаёт событие подключённым устройствам, кроме exclude.
func (s *server) send(devices []string, exclude string, ev hub.Event) {
for _, device := range devices {
if device == exclude {
continue
}
s.hub.Send(device, ev)
}
}
// 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{}
}
for _, key := range keys {
out.Keys = append(out.Keys, keyOut{KeyID: key.KeyID, From: key.From, IV: key.IV, CT: key.CT})
}
return out
}
// 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 keyList(member.Key)
}
}
return nil
}
// wrappedKeys проверяет форму завёрнутых ключей. Содержимое сервер
// не проверяет и проверить не может: это шифротекст (ADR-018).
func wrappedKeys(w http.ResponseWriter, in []keyIn) ([]store.WrappedKey, bool) {
out := make([]store.WrappedKey, 0, len(in))
seen := make(map[string]bool, len(in))
for _, k := range in {
if !validNick(k.To) {
Invalid(w, "keys", "keys[].to — не ник")
return nil, false
}
if seen[k.To] {
// Два ключа одному участнику — это множество keys[].to,
// не равное составу, а не отдельный отказ (ADR-043).
keysMismatch(w)
return nil, false
}
seen[k.To] = true
if _, ok := decodeExactly(k.IV, ivLen); !ok {
Invalid(w, "keys", "iv — не 12 байт base64url")
return nil, false
}
if ct, err := b64.DecodeString(k.CT); err != nil || len(ct) < minCTLen {
Invalid(w, "keys", "ct — не base64url или слишком короткий")
return nil, false
}
out = append(out, store.WrappedKey{To: k.To, IV: k.IV, CT: k.CT})
}
return out, true
}
// uniqueNicks разбирает список ников запроса: повторы схлопываются,
// порядок сохраняется. Второе значение — прошёл ли список проверку формы.
func uniqueNicks(list []string) ([]string, bool) {
out := make([]string, 0, len(list))
seen := make(map[string]bool, len(list))
for _, nick := range list {
if !validNick(nick) {
return nil, false
}
if seen[nick] {
continue
}
seen[nick] = true
out = append(out, nick)
}
return out, true
}
// validRoomName — имя комнаты: непустое, до 64 рун, без управляющих
// символов, без переопределений направления письма и не из одних
// пробелов (ADR-021).
//
// Форма строже, чем «до 64 символов», с этапа 4: имя комнаты уходит
// в заголовок системного уведомления (ADR-045), а туда нельзя ни перевод
// строки, ни разворот текста — на экране блокировки такое имя выглядит
// не строкой списка, а сообщением от системы.
func validRoomName(name string) bool {
if name == "" || utf8.RuneCountInString(name) > maxRoomName {
return false
}
blank := true
for _, r := range name {
if unicode.IsControl(r) || bidi(r) {
return false
}
if !unicode.IsSpace(r) {
blank = false
}
}
return !blank
}
// bidi — переопределения направления письма: U+202A…U+202E и U+2066…U+2069.
// Они переставляют текст на экране местами, оставаясь невидимыми.
func bidi(r rune) bool {
return (r >= 0x202A && r <= 0x202E) || (r >= 0x2066 && r <= 0x2069)
}