Этап 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
+26 -10
View File
@@ -74,14 +74,23 @@ func (s *server) sendMessage(w http.ResponseWriter, r *http.Request) {
return
}
if in.To.Room != "" {
// Комнаты — этап 3. Участников нет ни у одной комнаты, потому что
// нет и самих комнат: единственный возможный ответ — not_member.
Error(w, http.StatusForbidden, "not_member", "вы не участник комнаты")
return
}
sess, _ := auth.From(r)
if _, ok := s.peer(w, r, in.To.DM, sess.Nick); !ok {
room := in.To.Room != ""
if room {
member, knownKey, err := s.st.RoomAccess(r.Context(), in.To.Room, sess.Nick, in.KeyID)
if err != nil {
s.internal(w, r, err)
return
}
if !member {
Error(w, http.StatusForbidden, "not_member", "вы не участник комнаты")
return
}
if !knownKey {
Error(w, http.StatusBadRequest, "unknown_key", "у комнаты нет такого ключа")
return
}
} else if _, ok := s.peer(w, r, in.To.DM, sess.Nick); !ok {
return
}
@@ -92,7 +101,7 @@ func (s *server) sendMessage(w http.ResponseWriter, r *http.Request) {
env := envelope{
ID: in.ID,
To: target{DM: in.To.DM},
To: target{DM: in.To.DM, Room: in.To.Room},
From: sess.Nick,
KeyID: in.KeyID,
IV: in.IV,
@@ -104,14 +113,21 @@ func (s *server) sendMessage(w http.ResponseWriter, r *http.Request) {
s.internal(w, r, err)
return
}
devices, err := s.st.DeliverDM(r.Context(), store.Delivery{
delivery := store.Delivery{
From: env.From,
To: env.To.DM,
Room: env.To.Room,
Exclude: device,
MsgID: env.ID,
Envelope: string(raw),
Now: env.TS,
})
}
var devices []string
if room {
devices, err = s.st.DeliverRoom(r.Context(), delivery)
} else {
devices, err = s.st.DeliverDM(r.Context(), delivery)
}
if err != nil {
s.internal(w, r, err)
return