Files
mayatnikovandClaude Opus 5 05586218e1 Этап 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
2026-08-22 22:18:13 +03:00

118 lines
4.1 KiB
JavaScript

// Карточка контакта — docs/ui.md, «Карточка контакта».
//
// Отпечаток доверенного ключа, а если ключ ника изменился и ждёт
// подтверждения — оба отпечатка и «доверять новому ключу» (ADR-016).
import * as sync from "../sync.js";
import { fingerprintGroups } from "../crypto.js";
import { button, clear, el, message, setError, setNote } from "./dom.js";
// renderContact рисует карточку в root и отдаёт отписку.
export function renderContact(root, ctx, nick) {
const view = {
ctx,
nick,
alive: true,
// Событий «peers» приходит больше одного подряд; рисует последнее.
generation: 0,
};
root.append(head(ctx, nick));
const body = el("div", "body settings");
view.card = el("section", "block block--first");
body.append(view.card, remove(ctx, nick));
root.append(body);
const off = sync.on("peers", (detail) => {
if (detail.nick === view.nick) {
paint(view);
}
});
paint(view);
return () => {
view.alive = false;
off();
};
}
function head(ctx, nick) {
const bar = el("div", "head");
const back = el("button", "back", "назад");
back.type = "button";
back.addEventListener("click", () => ctx.go(`#/dm/${nick}`));
bar.append(back, el("span", "title", `@${nick}`));
return bar;
}
// paint рисует отпечатки. Записи TOFU может ещё не быть: чат открыли
// раньше, чем пришёл первый ключ.
async function paint(view) {
const mine = ++view.generation;
let record = null;
try {
record = await sync.peer(view.nick);
} catch {
// Базы нет — показывать нечего.
}
if (!view.alive || mine !== view.generation || !record?.fingerprint) {
return;
}
clear(view.card);
if (!record.pending) {
fingerprint(view.card, record.fingerprint, null);
view.card.append(el("p", "fp-hint", "сверьте с собеседником голосом или лично"));
return;
}
// Ключ изменился: показываем оба отпечатка с пометками, чтобы
// подтверждали не наугад (ADR-038).
fingerprint(view.card, record.fingerprint, "старый");
fingerprint(view.card, record.pending.fingerprint, "новый");
view.card.append(el("p", "fp-hint", "сверьте с собеседником голосом или лично"), trust(view));
}
// fingerprint — 64 hex группами по 4 в две строки (ADR-016).
function fingerprint(box, value, label) {
if (label) {
box.append(el("p", "fp-label", label));
}
const groups = fingerprintGroups(value);
box.append(el("p", "fp", groups.slice(0, 8).join(" ")), el("p", "fp", groups.slice(8).join(" ")));
}
// trust — «доверять новому ключу»: ключ из pending становится основным,
// и всё, что в него упиралось, повторяется (ADR-016).
function trust(view) {
const yes = button("доверять новому ключу");
yes.addEventListener("click", async () => {
yes.disabled = true;
try {
await sync.trustKey(view.nick);
} catch {
// Не вышло — запись цела, экран перерисуется с теми же ключами.
yes.disabled = false;
}
});
return yes;
}
// remove — «убрать из списка»: строка контакта уходит с сервера, история
// на устройстве остаётся (ADR-019).
function remove(ctx, nick) {
const box = el("section", "block");
const note = message();
const drop = button("убрать из списка");
drop.addEventListener("click", async () => {
drop.disabled = true;
setNote(note, "");
try {
await sync.forgetChat(sync.dmChatId(nick));
ctx.go("#/");
} catch (err) {
setError(note, ctx.errorText(err));
drop.disabled = false;
}
});
box.append(drop, note);
return box;
}