Этап 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
-18
@@ -1,33 +1,38 @@
|
||||
// Карточка контакта — docs/ui.md, «Карточка контакта».
|
||||
//
|
||||
// Смена ключа собеседника и «доверять новому ключу» появятся вместе
|
||||
// с TOFU (этап 3, ADR-016): до тех пор у записи peers нет pending.
|
||||
// Отпечаток доверенного ключа, а если ключ ника изменился и ждёт
|
||||
// подтверждения — оба отпечатка и «доверять новому ключу» (ADR-016).
|
||||
|
||||
import * as sync from "../sync.js";
|
||||
import { fingerprintGroups } from "../crypto.js";
|
||||
import { button, el, message, setError, setNote } from "./dom.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 card = el("section", "block block--first");
|
||||
body.append(card, remove(ctx, nick));
|
||||
|
||||
// Отпечаток лежит в записи TOFU; её может ещё не быть, если чат
|
||||
// открыли до первого ключа.
|
||||
sync.peer(nick).then((record) => {
|
||||
if (!record?.fingerprint || !card.isConnected) {
|
||||
return;
|
||||
const off = sync.on("peers", (detail) => {
|
||||
if (detail.nick === view.nick) {
|
||||
paint(view);
|
||||
}
|
||||
const groups = fingerprintGroups(record.fingerprint);
|
||||
card.append(
|
||||
el("p", "fp", groups.slice(0, 8).join(" ")),
|
||||
el("p", "fp", groups.slice(8).join(" ")),
|
||||
el("p", "fp-hint", "сверьте с собеседником голосом или лично"),
|
||||
);
|
||||
}).catch(() => {});
|
||||
});
|
||||
paint(view);
|
||||
|
||||
return () => {
|
||||
view.alive = false;
|
||||
off();
|
||||
};
|
||||
}
|
||||
|
||||
function head(ctx, nick) {
|
||||
@@ -39,6 +44,57 @@ function head(ctx, 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) {
|
||||
|
||||
Reference in New Issue
Block a user