Сервер: комнаты, состав и завёрнутые ключи; смена состава и 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
58 lines
2.1 KiB
Go
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, "")
|
|
}
|