Этап 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
+107 -3
View File
@@ -118,9 +118,14 @@ export function peerOf(chatId) {
return chatId.startsWith(DM) ? chatId.slice(DM.length) : null;
}
// roomIdOf — какая комната; у личного чата комнаты нет.
export function roomIdOf(chatId) {
return chatId.startsWith(ROOM) ? chatId.slice(ROOM.length) : null;
}
// blankChat — пустая запись чата по её ключу. title — имя без «@» и «#»:
// сигил ставит экран. Комнате имя приходит из GET /api/rooms (этап 3),
// до этого вместо имени стоит идентификатор.
// сигил ставит экран. Комнате имя, владелец и состав приходят из
// GET /api/rooms и события room; до этого вместо имени стоит идентификатор.
export function blankChat(id) {
const base = { id, title: "", lastId: null, lastReadId: null, unread: 0, hidden: false };
const peer = peerOf(id);
@@ -161,6 +166,39 @@ export function putChat(record) {
return put("chats", record);
}
// mergeChat правит поля чата, не трогая ленту и счётчики: чтение и запись
// одной транзакцией, чтобы не разъехаться с параллельным markRead.
// Недостающая запись заводится. Отдаёт, изменилось ли что-нибудь.
export async function mergeChat(id, patch) {
const db = await open();
const tx = db.transaction("chats", "readwrite");
const store = tx.objectStore("chats");
const known = await value(store.get(id));
const record = known ?? blankChat(id);
// Новую запись пишем всегда: она могла совпасть с пустой по всем полям
// патча и осталась бы ненаписанной.
let changed = known === undefined;
for (const [key, next] of Object.entries(patch)) {
if (same(record[key], next)) {
continue;
}
record[key] = next;
changed = true;
}
if (changed) {
store.put(record);
}
await done(tx);
return changed;
}
function same(a, b) {
if (Array.isArray(a) && Array.isArray(b)) {
return a.length === b.length && a.every((item, i) => item === b[i]);
}
return a === b;
}
// markRead — чат прочитан: счётчик обнуляется, граница «новых» уезжает
// к последнему сообщению. Обе величины локальные, на сервер не уходят
// (docs/storage.md).
@@ -307,6 +345,23 @@ export async function pendingMessages() {
return out;
}
// undecryptable — всё, что не удалось расшифровать и что хранит raw для
// повторной попытки (docs/storage.md). Индекса по этому в схеме нет, значит
// проход курсором; попытка повторяется редко: при подтверждении ключа
// и при появлении недостающего ключа комнаты.
export async function undecryptable() {
const db = await open();
const store = db.transaction("messages", "readonly").objectStore("messages");
const out = [];
await cursor(store.openCursor(), (record) => {
if (record.undecryptable && record.raw) {
out.push(record);
}
return true;
});
return out;
}
// cursor обходит курсор, пока step не скажет «хватит».
function cursor(request, step) {
return new Promise((resolve, reject) => {
@@ -322,10 +377,59 @@ function cursor(request, step) {
});
}
// --- ключи комнат -------------------------------------------------------
// Ключ хранилища roomKeys — [roomId, keyId]; массив больше любой строки,
// поэтому [roomId, []] — верхняя граница всех ключей комнаты, а [roomId] —
// нижняя (тот же приём, что и в индексе "chat").
function roomRange(roomId) {
return IDBKeyRange.bound([roomId], [roomId, []]);
}
export function roomKey(roomId, keyId) {
return get("roomKeys", [roomId, keyId]);
}
// roomKeysOf — все ключи комнаты по возрастанию receivedAt: последний
// и есть текущий (docs/storage.md, ADR-042).
export async function roomKeysOf(roomId) {
const db = await open();
const store = db.transaction("roomKeys", "readonly").objectStore("roomKeys");
const list = await value(store.getAll(roomRange(roomId)));
return list.sort((a, b) => a.receivedAt - b.receivedAt);
}
// saveRoomKey кладёт ключ комнаты, если такого ещё нет; отдаёт, случилось ли
// это. Клиент держит все ключи комнаты и расшифровывает любым известным
// (ADR-018), поэтому уже сохранённый ключ не перезаписывается.
//
// receivedAt строго больше времени всех прежних ключей комнаты: текущий ключ
// — последний полученный этим устройством, а два rekey подряд укладываются
// в одну миллисекунду (ADR-042, docs/storage.md).
export async function saveRoomKey({ roomId, keyId, key, from }) {
const db = await open();
const tx = db.transaction("roomKeys", "readwrite");
const store = tx.objectStore("roomKeys");
const list = await value(store.getAll(roomRange(roomId)));
let receivedAt = Date.now();
let known = false;
for (const record of list) {
known = known || record.keyId === keyId;
if (record.receivedAt >= receivedAt) {
receivedAt = record.receivedAt + 1;
}
}
if (!known) {
store.put({ roomId, keyId, key, from, receivedAt });
}
await done(tx);
return !known;
}
// --- собеседники --------------------------------------------------------
// peers — доверие к ключам, TOFU (ADR-016). Запись заводится при первом
// получении ключа; сверка изменившегося ключа и pending — этап 3.
// получении ключа; изменившийся ключ ложится в pending и ждёт подтверждения.
export function peer(nick) {
return get("peers", nick);
}