Files
mayatnikovandClaude Opus 5 63a7a1ef52 Этап 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
2026-08-22 18:18:04 +03:00

105 lines
3.7 KiB
JavaScript

// ULID — идентификатор сообщения: 48 бит миллисекунд и 80 бит случайности,
// Crockford base32, 26 символов (docs/crypto.md, «Идентификаторы»).
//
// Заглавные буквы обязательны: идентификатор входит в AAD шифротекста
// побайтно, и сервер строчные не принимает.
//
// Модуль не знает про DOM: его можно импортировать в node и прогнать.
// crockford — алфавит base32 без I, L, O и U.
const ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
const TIME_LEN = 10; // 50 бит, старшие два обязаны быть нулевыми
const RANDOM_LEN = 16; // 80 бит
const RANDOM_BYTES = 10;
export const ULID_LEN = TIME_LEN + RANDOM_LEN;
// MAX_TIME — предел 48 бит: дальше метка времени в ULID не помещается.
const MAX_TIME = 2 ** 48 - 1;
// Последняя выданная миллисекунда и её случайная часть. Внутри одной
// миллисекунды случайная часть инкрементируется (docs/crypto.md):
// два сообщения, набранные подряд, не получают одинаковый идентификатор
// и сортируются в порядке отправки.
let lastMs = -1;
const lastRandom = new Uint8Array(RANDOM_BYTES);
export function ulid(now = Date.now()) {
const ms = Math.floor(now);
if (!Number.isSafeInteger(ms) || ms < 0 || ms > MAX_TIME) {
throw new RangeError("время вне 48 бит");
}
if (ms === lastMs) {
bump(lastRandom);
} else {
lastMs = ms;
globalThis.crypto.getRandomValues(lastRandom);
}
return encodeTime(ms) + encodeRandom(lastRandom);
}
// ulidTime — метка времени идентификатора в миллисекундах; null, если
// это не ULID. Сервер считает ту же величину и сравнивает со своими
// часами: расхождение больше пяти минут — clock_skew (ADR-017).
export function ulidTime(id) {
if (typeof id !== "string" || id.length !== ULID_LEN) {
return null;
}
let ms = 0;
for (let i = 0; i < ULID_LEN; i += 1) {
const value = ALPHABET.indexOf(id[i]);
if (value < 0) {
return null;
}
if (i < TIME_LEN) {
ms = ms * 32 + value;
}
}
return ms > MAX_TIME ? null : ms;
}
export function validUlid(id) {
return ulidTime(id) !== null;
}
// bump увеличивает случайную часть на единицу. Переполнение всех 80 бит
// внутри одной миллисекунды невозможно на практике; если оно всё же
// случилось, берём новые случайные байты.
function bump(bytes) {
for (let i = bytes.length - 1; i >= 0; i -= 1) {
if (bytes[i] < 255) {
bytes[i] += 1;
return;
}
bytes[i] = 0;
}
globalThis.crypto.getRandomValues(bytes);
}
function encodeTime(ms) {
const out = new Array(TIME_LEN);
let rest = ms;
for (let i = TIME_LEN - 1; i >= 0; i -= 1) {
out[i] = ALPHABET[rest % 32];
rest = Math.floor(rest / 32);
}
return out.join("");
}
// encodeRandom режет 80 бит на 16 групп по 5: остатка нет.
function encodeRandom(bytes) {
let out = "";
let acc = 0;
let bits = 0;
for (let i = 0; i < bytes.length; i += 1) {
acc = (acc << 8) | bytes[i];
bits += 8;
while (bits >= 5) {
bits -= 5;
out += ALPHABET[(acc >>> bits) & 31];
}
}
return out;
}