Этап 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
This commit is contained in:
2026-08-22 22:18:13 +03:00
co-authored by Claude Opus 5
parent 63a7a1ef52
commit 05586218e1
38 changed files with 4692 additions and 201 deletions
+60 -2
View File
@@ -57,10 +57,12 @@ func (s *Store) Ack(ctx context.Context, device string, ids []string) error {
// Delivery — одна доставка: готовый конверт и всё, что нужно, чтобы
// разложить его по очередям. Envelope сервер не разбирает, поэтому id
// приходит отдельным полем.
// приходит отдельным полем. Заполнено ровно одно из To и Room — адресат
// у конверта один (docs/protocol.md, «Типы»).
type Delivery struct {
From string // отправитель, он же один из получателей
To string // собеседник
To string // собеседник личного чата
Room string // комната
Exclude string // устройство отправителя: эхо ему не нужно (ADR-017)
MsgID string // id конверта, вторая половина ключа очереди
Envelope string // готовый JSON конверта
@@ -109,6 +111,38 @@ func (s *Store) DeliverDM(ctx context.Context, d Delivery) ([]string, error) {
return devices, nil
}
// DeliverRoom кладёт конверт комнаты в очередь всех устройств всех
// участников, кроме отправившего (ADR-018), и возвращает эти устройства.
// Контактов у комнаты нет: список комнат клиент берёт из GET /api/rooms.
//
// Членство и keyId проверены раньше, отдельным запросом: между проверкой
// и этой транзакцией состав мог измениться, поэтому получателей она берёт
// из состава на момент доставки.
func (s *Store) DeliverRoom(ctx context.Context, d Delivery) ([]string, error) {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return nil, fmt.Errorf("store: доставка в комнату: %w", err)
}
defer tx.Rollback()
devices, err := roomDeviceIDs(ctx, tx, d.Room, d.Exclude)
if err != nil {
return nil, err
}
for _, id := range devices {
if _, err := tx.ExecContext(ctx, `
INSERT INTO queue (device_id, msg_id, envelope, created_at) VALUES (?, ?, ?, ?)
ON CONFLICT(device_id, msg_id) DO NOTHING`,
id, d.MsgID, d.Envelope, d.Now); err != nil {
return nil, fmt.Errorf("store: доставка в комнату (очередь): %w", err)
}
}
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("store: доставка в комнату: %w", err)
}
return devices, nil
}
// deviceIDs — устройства обоих собеседников, кроме отправившего.
func deviceIDs(ctx context.Context, tx *sql.Tx, from, to, exclude string) ([]string, error) {
rows, err := tx.QueryContext(ctx, `
@@ -131,3 +165,27 @@ func deviceIDs(ctx context.Context, tx *sql.Tx, from, to, exclude string) ([]str
}
return out, nil
}
// roomDeviceIDs — устройства всех участников комнаты, кроме отправившего.
func roomDeviceIDs(ctx context.Context, tx *sql.Tx, room, exclude string) ([]string, error) {
rows, err := tx.QueryContext(ctx, `
SELECT d.id FROM devices d JOIN room_members m ON m.nick = d.nick
WHERE m.room_id = ? AND d.id <> ? ORDER BY d.id`, room, exclude)
if err != nil {
return nil, fmt.Errorf("store: доставка в комнату (устройства): %w", err)
}
defer rows.Close()
var out []string
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
return nil, fmt.Errorf("store: доставка в комнату (устройства): %w", err)
}
out = append(out, id)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("store: доставка в комнату (устройства): %w", err)
}
return out, nil
}