Этап 2: чат 1:1 — устройства, очередь, SSE, шифрование сообщений

Сервер: регистрация устройств и X-Device, hub с одним потоком на устройство,
очередь per-device с фан-аутом без эха отправителю, POST /api/messages
с проверками в порядке protocol.md, ACK, SSE с воспроизведением очереди,
ready и пингом раз в 20 секунд, контакты в обе стороны при первом сообщении,
лимит 30 сообщений в минуту.

Клиент: ULID, ключ 1:1 из ECDH через HKDF, шифрование конверта с AAD,
sync.js как единственный писатель в IndexedDB, ACK строго после записи,
список чатов, экран чата по эталону, разделители дат и «новые»,
pending и failed с повтором, полоса «нет соединения».

ADR-033: у неотправленного есть текст отказа — clock_skew стало видно.
ADR-034: входящее с известным id не перезаписывает запись. Собеседник знает
открытый id конверта и подменял им чужое сообщение в чужой истории — вплоть
до стирания своего присланного, чего «удалить у всех не существует» не допускает.
ADR-035: один поток событий на браузерный профиль (locks + BroadcastChannel):
две вкладки отбирали поток друг у друга и оставались без живой доставки.
ADR-036: повтор отправки сохраняет ULID, пока он в пределах окна часов, —
иначе потерянный ответ давал у собеседника два сообщения вместо одного.

Приёмка на боевом сервере: два аккаунта, пять устройств, живая доставка,
копия на второе устройство, очередь офлайн-устройству, ACK, подмена from
игнорируется, чужой deviceId и запрос без Origin отбиваются, плейнтекста
в базе и WAL ноль вхождений.

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 18:18:04 +03:00
co-authored by Claude Opus 5
parent 597c55301c
commit 63a7a1ef52
39 changed files with 5126 additions and 46 deletions
+94
View File
@@ -13,11 +13,21 @@ const SALT_PREFIX = "bare-v1:";
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 MSG_AAD = "bare-msg-v1|";
// DM_KEY_ID — keyId личного чата: ключ выводится из ECDH, отдельного
// идентификатора у него нет (docs/crypto.md, «Сообщение»).
export const DM_KEY_ID = "dm";
// Длина секрета аккаунта (ADR-014) и вектора инициализации AES-GCM.
export const SECRET_LEN = 32;
const IV_LEN = 12;
// ID_LEN — deviceId, keyId и roomId устроены одинаково: 16 случайных
// байт base64url, 22 символа (docs/crypto.md, «Идентификаторы»).
const ID_LEN = 16;
// Границы числа итераций PBKDF2 (ADR-013, ADR-030). Число приходит от
// сервера — в ответе /api/kdf, /api/config или полем iter в блобе, — а
// считает по нему клиент, поэтому проверить его может только он.
@@ -47,6 +57,11 @@ export function random(length) {
return bytes;
}
// newId — идентификатор устройства, ключа комнаты или комнаты.
export function newId() {
return b64url(random(ID_LEN));
}
export function b64url(input) {
const bytes = input instanceof Uint8Array ? input : new Uint8Array(input);
let binary = "";
@@ -240,3 +255,82 @@ export async function openBlob(blob, kek, nick) {
}
return { priv: parsed.priv, secret };
}
// --- чат 1:1 -----------------------------------------------------------
// order — ники пары по возрастанию. Сравниваются кодовые единицы, а не
// буквы языка: ник — это [a-z0-9_], и порядок обязан совпасть у обеих
// сторон побайтно (docs/crypto.md, «Чат 1:1»).
export function order(a, b) {
return a < b ? [a, b] : [b, a];
}
// dmLabel — метка чата для AAD сообщения: "dm:" + a + ":" + b.
// Это не ключ хранилища chats: там чат зовётся "dm:<собеседник>".
export function dmLabel(a, b) {
const [first, second] = order(a, b);
return `dm:${first}:${second}`;
}
// dmKey выводит ключ личного чата (docs/crypto.md, «Чат 1:1»).
// Ключ симметричен для обеих сторон и всех их устройств; в IndexedDB
// не пишется — выводится заново из peers.
export async function dmKey(privateKey, peerPublicJwk, me, peer) {
const [a, b] = order(me, peer);
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(DM_SALT), info: utf8(`${a}\0${b}`) },
material,
{ name: "AES-GCM", length: 256 },
false,
["encrypt", "decrypt"],
);
}
// --- сообщение ---------------------------------------------------------
// messageAad привязывает открытые поля конверта к шифротексту: подмена
// любого из них ломает расшифровку (docs/crypto.md, «Сообщение»).
function messageAad({ id, chat, from, keyId }) {
return utf8(`${MSG_AAD}${id}|${chat}|${from}|${keyId}`);
}
// sealMessage шифрует текст сообщения. plain — JSON {"t": текст};
// ничего кроме текста внутрь не кладётся.
export async function sealMessage(key, { id, chat, from, keyId, text }) {
const iv = random(IV_LEN);
const plain = utf8(JSON.stringify({ t: text }));
const ct = await subtle.encrypt(
{ name: "AES-GCM", iv, additionalData: messageAad({ id, chat, from, keyId }) },
key,
plain,
);
wipe(plain);
return { iv: b64url(iv), ct: b64url(ct) };
}
// openMessage расшифровывает конверт и отдаёт текст. Бросает при любой
// порче: не тот ключ, изменившиеся открытые поля, битый base64url.
// Для вызывающего это не фатально — сообщение сохраняется нерасшифрованным
// с кодом причины (docs/storage.md).
export async function openMessage(key, { id, chat, from, keyId, iv, ct }) {
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: messageAad({ id, chat, from, keyId }) },
key,
unb64url(ct),
);
const bytes = new Uint8Array(plain);
const parsed = JSON.parse(decoder.decode(bytes));
wipe(bytes);
if (parsed === null || typeof parsed !== "object" || typeof parsed.t !== "string") {
throw new Error("в сообщении нет текста");
}
return parsed.t;
}