Сервер: регистрация устройств и 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
105 lines
3.7 KiB
JavaScript
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;
|
|
}
|