Этап 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:
+74
-29
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user