Этап 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
+90
View File
@@ -14,6 +14,8 @@ const INFO_AUTH = "bare-auth-v1";
const INFO_KEK = "bare-kek-v1";
const BLOB_AAD = "bare-blob-v1|";
const DM_SALT = "bare-dm-v1";
const WRAP_SALT = "bare-wrap-v1";
const ROOM_AAD = "bare-roomkey-v1|";
const MSG_AAD = "bare-msg-v1|";
// DM_KEY_ID — keyId личного чата: ключ выводится из ECDH, отдельного
@@ -24,6 +26,10 @@ export const DM_KEY_ID = "dm";
export const SECRET_LEN = 32;
const IV_LEN = 12;
// ROOM_KEY_LEN — ключ комнаты: 32 случайных байта (docs/crypto.md,
// «Комната»).
export const ROOM_KEY_LEN = 32;
// ID_LEN — deviceId, keyId и roomId устроены одинаково: 16 случайных
// байт base64url, 22 символа (docs/crypto.md, «Идентификаторы»).
const ID_LEN = 16;
@@ -290,6 +296,90 @@ export async function dmKey(privateKey, peerPublicJwk, me, peer) {
);
}
// --- комната -----------------------------------------------------------
// roomLabel — метка комнаты для AAD сообщения: "room:" + roomId. Совпадает
// с ключом чата в IndexedDB, но собирается здесь: crypto.js не знает о базе.
export function roomLabel(roomId) {
return `room:${roomId}`;
}
// newRoomKey — новый ключ комнаты: 32 случайных байта и случайный keyId
// (docs/crypto.md, «Комната»). Сырые байты живут до конца заворачивания,
// потом распространитель импортирует их себе non-extractable и затирает.
export function newRoomKey() {
return { keyId: newId(), bytes: random(ROOM_KEY_LEN) };
}
// importRoomKey — ключ комнаты как non-extractable AES-GCM-256. Наружу
// он больше не выходит: в IndexedDB кладётся объект CryptoKey.
export function importRoomKey(bytes) {
return subtle.importKey("raw", bytes, { name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"]);
}
// roomAad и wrapKey — ровно то, что написано в docs/crypto.md,
// «Заворачивание участнику». Заворачивание самому себе идёт этим же кодом:
// ECDH(myPrivate, myPublic), без исключений.
function roomAad({ roomId, keyId, from, to }) {
return utf8(`${ROOM_AAD}${roomId}|${keyId}|${from}|${to}`);
}
async function wrapKey(privateKey, peerPublicJwk, roomId, keyId) {
const publicKey = await importPublic(peerPublicJwk);
const shared = await subtle.deriveBits({ name: "ECDH", public: publicKey }, privateKey, 256);
const material = await subtle.importKey("raw", shared, "HKDF", false, ["deriveKey"]);
wipe(new Uint8Array(shared));
return subtle.deriveKey(
{
name: "HKDF",
hash: "SHA-256",
salt: utf8(WRAP_SALT),
info: utf8(`${ROOM_AAD}${roomId}|${keyId}`),
},
material,
{ name: "AES-GCM", length: 256 },
false,
["encrypt", "decrypt"],
);
}
// wrapRoomKey заворачивает сырые байты ключа комнаты участнику. Отдаёт
// запись keys[] запроса: {to, iv, ct} (docs/protocol.md, «Типы»).
export async function wrapRoomKey(privateKey, memberPublicJwk, { roomId, keyId, from, to }, roomKey) {
const key = await wrapKey(privateKey, memberPublicJwk, roomId, keyId);
const iv = random(IV_LEN);
const ct = await subtle.encrypt(
{ name: "AES-GCM", iv, additionalData: roomAad({ roomId, keyId, from, to }) },
key,
roomKey,
);
return { to, iv: b64url(iv), ct: b64url(ct) };
}
// unwrapRoomKey разворачивает завёрнутый нам ключ той же схемой и сразу
// импортирует его non-extractable: сырые байты дальше не идут.
// Публичный ключ отправителя проходит через TOFU до вызова (ADR-016).
export async function unwrapRoomKey(privateKey, senderPublicJwk, { roomId, keyId, from, to, iv, ct }) {
const key = await wrapKey(privateKey, senderPublicJwk, roomId, keyId);
const nonce = unb64url(iv);
if (nonce.length !== IV_LEN) {
throw new Error("iv — не 12 байт");
}
const plain = await subtle.decrypt(
{ name: "AES-GCM", iv: nonce, additionalData: roomAad({ roomId, keyId, from, to }) },
key,
unb64url(ct),
);
const bytes = new Uint8Array(plain);
if (bytes.length !== ROOM_KEY_LEN) {
wipe(bytes);
throw new Error("ключ комнаты — не 32 байта");
}
const imported = await importRoomKey(bytes);
wipe(bytes);
return imported;
}
// --- сообщение ---------------------------------------------------------
// messageAad привязывает открытые поля конверта к шифротексту: подмена