Этап 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:
@@ -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 привязывает открытые поля конверта к шифротексту: подмена
|
||||
|
||||
Reference in New Issue
Block a user