Сервер: комнаты, состав и завёрнутые ключи; смена состава и 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
118 lines
4.1 KiB
JavaScript
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;
|
|
}
|