Files
mayatnikovandClaude Opus 5 05586218e1 Этап 3: TOFU и комнаты — ключ комнаты, атомарный rekey, участники
Сервер: комнаты, состав и завёрнутые ключи; смена состава и rekey одним
атомарным запросом с проверкой keys[].to против итогового состава; обрезка
до двух последних ключей; передача владения по joined_at; события room
каждому со своим ключом и room_left удалённым; членство и keyId в проверках
сообщения; миграция 002.

Клиент: TOFU на всех путях, по которым публичный ключ доходит до клиента;
предупреждение о смене ключа с блокировкой отправки и повторной расшифровкой
сохранённого raw; заворачивание и разворачивание ключей комнаты, включая
себе — тем же кодом, без ветвления; расшифровка любым известным keyId;
экраны участников, создание комнаты, карточка контакта с двумя отпечатками.

ADR-037: roomId генерирует клиент. crypto.md вплетает roomId в заворачивание,
а оно происходит до запроса — создатель обязан привязать ключ к идентификатору,
которого по прежнему протоколу ещё не существовало.
ADR-039: завёрнутый ключ принимается только от участника. Иначе сервер
подставляет ключ, завёрнутый посторонним аккаунтом, TOFU молчит — ник
незнакомый, первый ключ запоминается молча, — и комната уезжает на ключ
сервера. Одно подменённое поле в ответе, без сговора и подмены кода.
ADR-041: долг по rekey — состояние комнаты, а не свойство события. Владелец,
офлайн в момент выхода участника, не узнавал о долге никогда, и комната
навсегда оставалась на ключе, который вышедший знает.
ADR-038, 040, 042, 043, 044: тексты экранов комнат, снятие pending при
возврате прежнего ключа, порядок ключей, форма раньше прав, экран покинутой
комнаты.

Приёмка на боевом: комната на троих, добавленный четвёртый читает только
новое, вышедший после rekey новых не получает и писать не может,
keys_mismatch, key_exists, not_owner, owner, room_conflict, чужой X-Device.
Подмена public_key в базе даёт у собеседника предупреждение и блокирует
отправку — проверено в Chrome.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015DbCjVfTFq4ZFG8juD45YJ
2026-08-22 22:18:13 +03:00

58 lines
2.1 KiB
Go

package store
import (
"context"
"fmt"
"time"
)
// Сроки хранения из docs/storage.md.
const (
queueTTL = 30 * 24 * time.Hour // недоставленное сообщение
deviceTTL = 90 * 24 * time.Hour // молчащее устройство
roomKeysKept = 2 // ключей комнаты на комнату
cleanupEvery = time.Hour
)
// RunCleanup чистит базу раз в час, пока не отменён ctx. Первый проход —
// сразу при старте: сервер, который перезапускают чаще раза в час, иначе
// не чистился бы никогда. Ошибку отдаёт report; nil — молчать.
func (s *Store) RunCleanup(ctx context.Context, report func(error)) {
tick := time.NewTicker(cleanupEvery)
defer tick.Stop()
for {
if err := s.Cleanup(ctx, time.Now()); err != nil && report != nil && ctx.Err() == nil {
report(err)
}
select {
case <-ctx.Done():
return
case <-tick.C:
}
}
}
// Cleanup выполняет один проход чистки (docs/storage.md, «Фоновая чистка»).
func (s *Store) Cleanup(ctx context.Context, now time.Time) error {
ms := now.UnixMilli()
steps := []struct {
what string
query string
args []any
}{
{"очередь", `DELETE FROM queue WHERE created_at < ?`, []any{ms - queueTTL.Milliseconds()}},
{"устройства", `DELETE FROM devices WHERE last_seen < ?`, []any{ms - deviceTTL.Milliseconds()}},
{"сессии", `DELETE FROM sessions WHERE expires_at < ?`, []any{ms}},
}
for _, step := range steps {
if _, err := s.db.ExecContext(ctx, step.query, step.args...); err != nil {
return fmt.Errorf("store: чистка (%s): %w", step.what, err)
}
}
// Ключи комнат: у каждой комнаты остаются два последних key_id. Тем же
// запросом обрезает их rekey (internal/store/rooms.go): порядок один,
// иначе чистка и rekey держали бы разные ключи.
return trimRoomKeys(ctx, s.db, "")
}