Этап 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
+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())
}
}