Этап 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
+74 -29
View File
@@ -1,61 +1,106 @@
// Новый чат — docs/ui.md, «Новый чат». Строка `#имя комнаты` появится
// вместе с комнатами (этап 3): создавать пока нечего.
// Новый чат — docs/ui.md, «Новый чат». Две строки ввода: `@ник` открывает
// личный чат, `#имя комнаты` заводит комнату.
import * as sync from "../sync.js";
import { el, message, setError, setNote } from "./dom.js";
// Имя комнаты — до 64 символов (ADR-018, ADR-021). maxLength считает
// единицы UTF-16, а сервер — руны: за предел это не выпустит.
const ROOM_NAME_MAX = 64;
export function renderNew(root, ctx) {
root.append(head(ctx));
const body = el("div", "body");
const form = el("form", "form");
form.noValidate = true;
const row = el("div", "input");
const prompt = el("span", "p", ">");
prompt.setAttribute("aria-hidden", "true");
const field = el("input", "input__field");
field.type = "text";
field.placeholder = "@ник";
field.autocapitalize = "off";
field.autocomplete = "off";
field.spellcheck = false;
const go = el("button", "input__send", ">");
go.type = "submit";
row.append(prompt, field, go);
// Место под ошибку одно на оба поля: строка состояния у экрана одна
// (ADR-028).
const note = message();
form.append(row, note);
form.addEventListener("submit", async (event) => {
const dm = row("@ник");
const room = row("#имя комнаты");
room.field.maxLength = ROOM_NAME_MAX;
dm.form.addEventListener("submit", async (event) => {
event.preventDefault();
if (go.disabled) {
if (dm.go.disabled) {
return;
}
setNote(note, "");
// Ник вводят как в списке: с «@» или без. Регистр не хранится —
// ники строчные (ADR-019).
const nick = field.value.trim().replace(/^@/, "").toLowerCase();
const nick = dm.field.value.trim().replace(/^@/, "").toLowerCase();
if (nick === "") {
field.focus();
dm.field.focus();
return;
}
field.value = nick;
go.disabled = true;
dm.field.value = nick;
dm.go.disabled = true;
try {
await sync.openDm(nick);
ctx.go(`#/dm/${nick}`);
} catch (err) {
setError(note, ctx.errorText(err));
field.focus();
dm.field.focus();
} finally {
go.disabled = false;
dm.go.disabled = false;
}
});
body.append(form);
room.form.addEventListener("submit", async (event) => {
event.preventDefault();
if (room.go.disabled) {
return;
}
setNote(note, "");
// Имя вводят как в списке: с «#» или без. Регистр имени комнаты
// сохраняется — это её название, а не идентификатор.
const name = room.field.value.trim().replace(/^#/, "").trim();
if (name === "") {
room.field.focus();
return;
}
room.go.disabled = true;
try {
const chatId = await sync.createRoom(name);
if (chatId === null) {
room.field.focus();
return;
}
ctx.go(`#/room/${sync.roomIdOf(chatId)}`);
} catch (err) {
setError(note, ctx.errorText(err));
room.field.focus();
} finally {
room.go.disabled = false;
}
});
body.append(dm.form, room.form, note);
root.append(body);
field.focus();
dm.field.focus();
}
// row — строка ввода в стиле чата: рамка 1 px ink, слева «>» цветом mark
// (docs/identity/brief.md, «Компоновка»).
function row(placeholder) {
const form = el("form", "form form--row");
form.noValidate = true;
const line = el("div", "input");
const prompt = el("span", "p", ">");
prompt.setAttribute("aria-hidden", "true");
const field = el("input", "input__field");
field.type = "text";
field.placeholder = placeholder;
field.autocapitalize = "off";
field.autocomplete = "off";
field.spellcheck = false;
const go = el("button", "input__send", ">");
go.type = "submit";
line.append(prompt, field, go);
form.append(line);
return { form, field, go };
}
function head(ctx) {