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

365 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 store
import (
"context"
"errors"
"path/filepath"
"testing"
"time"
)
// room — комната из трёх ников для тестов хранилища.
func newTestStore(t *testing.T, nicks ...string) (*Store, context.Context) {
t.Helper()
ctx := context.Background()
s := open(t, filepath.Join(t.TempDir(), "bare.db"))
for i, nick := range nicks {
err := s.CreateUser(ctx, User{
Nick: nick,
Cred: Credential{Hash: []byte("hash"), Salt: []byte("salt"), Params: "argon2id,m=19456,t=2,p=1"},
PublicKey: `{"kty":"EC"}`,
KeyBlob: `{"v":1}`,
CreatedAt: int64(i + 1),
})
if err != nil {
t.Fatalf("CreateUser %s: %v", nick, err)
}
}
return s, ctx
}
// wrap — завёрнутый ключ участнику: содержимое хранилищу безразлично.
func wrap(nicks ...string) []WrappedKey {
out := make([]WrappedKey, 0, len(nicks))
for _, nick := range nicks {
out = append(out, WrappedKey{To: nick, IV: "iv-" + nick, CT: "ct-" + nick})
}
return out
}
// rekey — смена состава и раздача нового ключа.
func rekey(t *testing.T, s *Store, ctx context.Context, room, owner, keyID string, add, remove, to []string, now int64) RoomChange {
t.Helper()
change, err := s.UpdateMembers(ctx, MembersChange{
RoomID: room,
Owner: owner,
Add: add,
Remove: remove,
KeyID: keyID,
Keys: wrap(to...),
Now: now,
})
if err != nil {
t.Fatalf("UpdateMembers %s: %v", keyID, err)
}
return change
}
// makeRoom — комната с одним владельцем и его ключом.
func makeRoom(t *testing.T, s *Store, ctx context.Context, owner string, now int64) RoomChange {
t.Helper()
change, err := s.CreateRoom(ctx, NewRoom{
ID: "room-1",
Name: "общая",
Owner: owner,
KeyID: "k1",
Key: wrap(owner)[0],
Now: now,
})
if err != nil {
t.Fatalf("CreateRoom: %v", err)
}
return change
}
// Занятый идентификатор комнаты — ErrRoomExists, и ни одной строки
// существующая комната при этом не теряет (ADR-037).
func TestCreateRoomTakenID(t *testing.T) {
s, ctx := newTestStore(t, "marta", "petya")
makeRoom(t, s, ctx, "marta", 1000)
_, err := s.CreateRoom(ctx, NewRoom{ID: "room-1", Name: "чужая", Owner: "petya",
KeyID: "k2", Key: wrap("petya")[0], Now: 2000})
if !errors.Is(err, ErrRoomExists) {
t.Fatalf("CreateRoom с занятым id: получено %v, ожидалось ErrRoomExists", err)
}
rooms, err := s.Rooms(ctx, "marta")
if err != nil {
t.Fatalf("Rooms: %v", err)
}
if len(rooms) != 1 || rooms[0].Name != "общая" || rooms[0].Owner != "marta" ||
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 {
t.Errorf("занятый id присоединил чужого: %+v, %v", got, err)
}
}
// 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) {
s, ctx := newTestStore(t, "marta", "petya", "kolya")
makeRoom(t, s, ctx, "marta", 1000)
rekey(t, s, ctx, "room-1", "marta", "k2", []string{"petya"}, nil, []string{"marta", "petya"}, 2000)
rekey(t, s, ctx, "room-1", "marta", "k3", []string{"kolya"}, nil, []string{"marta", "petya", "kolya"}, 3000)
if got := ids(t, s, `SELECT DISTINCT key_id FROM room_keys ORDER BY key_id`); !equal(got, []string{"k2", "k3"}) {
t.Errorf("ключи после rekey: получено %v, ожидалось [k2 k3]", got)
}
// Фоновая чистка ничего не добавляет к обрезке: запрос у них один.
if err := s.Cleanup(ctx, time.Now()); err != nil {
t.Fatalf("Cleanup: %v", err)
}
if got := ids(t, s, `SELECT DISTINCT key_id FROM room_keys ORDER BY key_id`); !equal(got, []string{"k2", "k3"}) {
t.Errorf("ключи после чистки: получено %v, ожидалось [k2 k3]", got)
}
// Текущий ключ есть у каждого участника, и он последний.
for _, nick := range []string{"marta", "petya", "kolya"} {
rooms, err := s.Rooms(ctx, nick)
if err != nil {
t.Fatalf("Rooms %s: %v", nick, err)
}
if len(rooms) != 1 || current(rooms[0]) == nil {
t.Fatalf("комнаты %s: %+v", nick, rooms)
}
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 в одну миллисекунду: текущим остаётся последний розданный ключ,
// и обрезка его не выбрасывает (docs/storage.md, «Текущий ключ комнаты»).
// Идентификаторы ключей случайны, поэтому свежий вполне может оказаться
// меньше прежнего по порядку сортировки — на этом и построен случай.
func TestRoomKeysWithinOneMillisecond(t *testing.T) {
s, ctx := newTestStore(t, "marta", "petya")
if _, err := s.CreateRoom(ctx, NewRoom{ID: "room-1", Name: "общая", Owner: "marta",
KeyID: "ccc", Key: wrap("marta")[0], Now: 1000}); err != nil {
t.Fatalf("CreateRoom: %v", err)
}
rekey(t, s, ctx, "room-1", "marta", "bbb", []string{"petya"}, nil, []string{"marta", "petya"}, 1000)
rekey(t, s, ctx, "room-1", "marta", "aaa", nil, nil, []string{"marta", "petya"}, 1000)
if got := ids(t, s, `SELECT DISTINCT key_id FROM room_keys ORDER BY key_id`); !equal(got, []string{"aaa", "bbb"}) {
t.Errorf("ключи: получено %v, ожидалось [aaa bbb]", got)
}
for _, nick := range []string{"marta", "petya"} {
rooms, err := s.Rooms(ctx, nick)
if err != nil || len(rooms) != 1 || current(rooms[0]) == nil {
t.Fatalf("комнаты %s: %+v, %v", nick, rooms, err)
}
if current(rooms[0]).KeyID != "aaa" {
t.Errorf("текущий ключ %s: получено %q, ожидалось \"aaa\"", nick, current(rooms[0]).KeyID)
}
}
// Свежим ключом можно писать: он остался ключом комнаты.
access, err := s.RoomAccess(ctx, "room-1", "marta", "aaa")
if err != nil || !access.Member || !access.KnownKey {
t.Errorf("доступ по свежему ключу: %+v, %v", access, err)
}
if access.Name != "общая" {
t.Errorf("имя комнаты: получено %q, ожидалось \"общая\"", access.Name)
}
}
// Убранный участник теряет и членство, и все свои ключи.
func TestRoomRemoveDropsKeys(t *testing.T) {
s, ctx := newTestStore(t, "marta", "petya")
makeRoom(t, s, ctx, "marta", 1000)
rekey(t, s, ctx, "room-1", "marta", "k2", []string{"petya"}, nil, []string{"marta", "petya"}, 2000)
change := rekey(t, s, ctx, "room-1", "marta", "k3", nil, []string{"petya"}, []string{"marta"}, 3000)
if len(change.Left) != 1 || change.Left[0].Nick != "petya" {
t.Errorf("выбывшие: %+v", change.Left)
}
if got := ids(t, s, `SELECT nick FROM room_keys ORDER BY nick, key_id`); !equal(got, []string{"marta", "marta"}) {
t.Errorf("ключи после удаления участника: %v", got)
}
rooms, err := s.Rooms(ctx, "petya")
if err != nil || len(rooms) != 0 {
t.Errorf("комнаты убранного: %+v, %v", rooms, err)
}
}
// Отказ в середине не оставляет следов: состав и ключи те же (docs/protocol.md).
func TestRoomChangeIsAtomic(t *testing.T) {
s, ctx := newTestStore(t, "marta", "petya", "kolya")
makeRoom(t, s, ctx, "marta", 1000)
rekey(t, s, ctx, "room-1", "marta", "k2", []string{"petya"}, nil, []string{"marta", "petya"}, 2000)
cases := []struct {
name string
c MembersChange
want error
}{
{"не владелец", MembersChange{RoomID: "room-1", Owner: "petya", KeyID: "k3",
Keys: wrap("marta", "petya"), Now: 3000}, ErrNotOwner},
{"нет комнаты", MembersChange{RoomID: "нет", Owner: "marta", KeyID: "k3",
Keys: wrap("marta", "petya"), Now: 3000}, ErrNotOwner},
{"нет ника", MembersChange{RoomID: "room-1", Owner: "marta", Add: []string{"никого"}, KeyID: "k3",
Keys: wrap("marta", "petya", "никого"), Now: 3000}, ErrUnknownUser},
{"не участник", MembersChange{RoomID: "room-1", Owner: "marta", Remove: []string{"kolya"}, KeyID: "k3",
Keys: wrap("marta", "petya"), Now: 3000}, ErrNotMember},
{"владелец", MembersChange{RoomID: "room-1", Owner: "marta", Remove: []string{"marta"}, KeyID: "k3",
Keys: wrap("petya"), Now: 3000}, ErrOwnerRemoval},
{"ключ уже был", MembersChange{RoomID: "room-1", Owner: "marta", Add: []string{"kolya"}, KeyID: "k2",
Keys: wrap("marta", "petya", "kolya"), Now: 3000}, ErrKeyExists},
{"ключи не по составу", MembersChange{RoomID: "room-1", Owner: "marta", Add: []string{"kolya"}, KeyID: "k3",
Keys: wrap("marta", "petya"), Now: 3000}, ErrKeysMismatch},
// Два ключа одному вместо ключа другому: длина сходится, состав — нет.
{"два ключа одному", MembersChange{RoomID: "room-1", Owner: "marta", KeyID: "k3",
Keys: wrap("marta", "marta"), Now: 3000}, ErrKeysMismatch},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if _, err := s.UpdateMembers(ctx, c.c); !errors.Is(err, c.want) {
t.Fatalf("получено %v, ожидалось %v", err, c.want)
}
if got := ids(t, s, `SELECT nick FROM room_members WHERE room_id = 'room-1' ORDER BY nick`); !equal(got, []string{"marta", "petya"}) {
t.Errorf("состав: получено %v, ожидалось [marta petya]", got)
}
if got := ids(t, s, `SELECT DISTINCT key_id FROM room_keys ORDER BY key_id`); !equal(got, []string{"k1", "k2"}) {
t.Errorf("ключи: получено %v, ожидалось [k1 k2]", got)
}
})
}
}
// Удаление аккаунта передаёт владение участнику с наименьшим joined_at,
// а комнату без участников удаляет (ADR-018).
func TestDeleteUserRooms(t *testing.T) {
s, ctx := newTestStore(t, "marta", "petya", "kolya")
makeRoom(t, s, ctx, "marta", 1000)
rekey(t, s, ctx, "room-1", "marta", "k2", []string{"kolya"}, nil, []string{"marta", "kolya"}, 2000)
rekey(t, s, ctx, "room-1", "marta", "k3", []string{"petya"}, nil, []string{"marta", "kolya", "petya"}, 3000)
// Вторая комната — только владелец.
if _, err := s.CreateRoom(ctx, NewRoom{ID: "room-2", Name: "своя", Owner: "marta",
KeyID: "k1", Key: wrap("marta")[0], Now: 4000}); err != nil {
t.Fatalf("CreateRoom: %v", err)
}
if _, err := s.DeleteUser(ctx, "marta"); err != nil {
t.Fatalf("DeleteUser: %v", err)
}
if got := ids(t, s, `SELECT id FROM rooms ORDER BY id`); !equal(got, []string{"room-1"}) {
t.Errorf("комнаты: получено %v, ожидалось [room-1]", got)
}
if got := ids(t, s, `SELECT owner FROM rooms`); !equal(got, []string{"kolya"}) {
t.Errorf("владелец: получено %v, ожидалось [kolya]", got)
}
if got := ids(t, s, `SELECT nick FROM room_members ORDER BY nick`); !equal(got, []string{"kolya", "petya"}) {
t.Errorf("состав: получено %v, ожидалось [kolya petya]", got)
}
if got := ids(t, s, `SELECT DISTINCT nick FROM room_keys ORDER BY nick`); !equal(got, []string{"kolya", "petya"}) {
t.Errorf("ключи: получено %v, ожидалось [kolya petya]", got)
}
}
// Удаление аккаунта простого участника чужую комнату не трогает: членство
// и ключи уносит каскад, владелец и остальные на месте.
func TestDeleteUserMember(t *testing.T) {
s, ctx := newTestStore(t, "marta", "petya")
if _, err := s.CreateRoom(ctx, NewRoom{ID: "room-1", Name: "общая", Owner: "petya",
KeyID: "k1", Key: wrap("petya")[0], Now: 1000}); err != nil {
t.Fatalf("CreateRoom: %v", err)
}
rekey(t, s, ctx, "room-1", "petya", "k2", []string{"marta"}, nil, []string{"petya", "marta"}, 2000)
if _, err := s.DeleteUser(ctx, "marta"); err != nil {
t.Fatalf("DeleteUser: %v", err)
}
if got := ids(t, s, `SELECT owner FROM rooms`); !equal(got, []string{"petya"}) {
t.Errorf("комнаты: получено %v, ожидалось [petya]", got)
}
if got := ids(t, s, `SELECT nick FROM room_members`); !equal(got, []string{"petya"}) {
t.Errorf("состав: получено %v, ожидалось [petya]", got)
}
if got := ids(t, s, `SELECT DISTINCT nick FROM room_keys`); !equal(got, []string{"petya"}) {
t.Errorf("ключи: получено %v, ожидалось [petya]", got)
}
}
// Выход владельца передаёт владение; выход последнего удаляет комнату.
func TestLeaveRoom(t *testing.T) {
s, ctx := newTestStore(t, "marta", "petya")
makeRoom(t, s, ctx, "marta", 1000)
rekey(t, s, ctx, "room-1", "marta", "k2", []string{"petya"}, nil, []string{"marta", "petya"}, 2000)
// Не участник — ErrNotFound, комната не тронута.
if _, err := s.LeaveRoom(ctx, "нет", "marta"); !errors.Is(err, ErrNotFound) {
t.Errorf("выход из несуществующей комнаты: %v", err)
}
change, err := s.LeaveRoom(ctx, "room-1", "marta")
if err != nil {
t.Fatalf("LeaveRoom: %v", err)
}
if change.Room.Owner != "petya" {
t.Errorf("владелец: получено %q, ожидалось \"petya\"", change.Room.Owner)
}
if len(change.Members) != 1 || change.Members[0].Nick != "petya" {
t.Fatalf("оставшиеся: %+v", change.Members)
}
if change.Members[0].Key == nil || change.Members[0].Key.KeyID != "k2" {
t.Errorf("ключ оставшегося: %+v", change.Members[0].Key)
}
if !change.Room.NeedsRekey {
t.Error("needsRekey после выхода участника")
}
if len(change.Left) != 1 || change.Left[0].Nick != "marta" {
t.Errorf("room_left вышедшему: %+v", change.Left)
}
if got := ids(t, s, `SELECT DISTINCT nick FROM room_keys`); !equal(got, []string{"petya"}) {
t.Errorf("ключи после выхода: %v", got)
}
last, err := s.LeaveRoom(ctx, "room-1", "petya")
if err != nil {
t.Fatalf("LeaveRoom: %v", err)
}
// Комнаты больше нет: room слать некому, room_left уходит другим
// устройствам вышедшего (ADR-041).
if len(last.Members) != 0 {
t.Errorf("получатели после выхода последнего: %+v", last.Members)
}
if len(last.Left) != 1 || last.Left[0].Nick != "petya" {
t.Errorf("room_left после выхода последнего: %+v", last.Left)
}
if got := ids(t, s, `SELECT id FROM rooms`); len(got) != 0 {
t.Errorf("пустая комната осталась: %v", got)
}
if got := ids(t, s, `SELECT DISTINCT nick FROM room_keys`); len(got) != 0 {
t.Errorf("ключи удалённой комнаты остались: %v", got)
}
}