Этап 6: закалка — лимиты ADR-021, аудит модели угроз, сверка документов

Лимиты: все четыре правила ADR-021 — регистрация 5/час на IP, вход 10/10 мин
на IP и ник, сообщения 30/мин, прочие изменяющие 60/мин; 429 с Retry-After;
X-Real-IP читается только с loopback, иначе адрес соединения — иначе заголовок
отменял бы лимит на IP; карты вёдер ограничены поколениями.

Аудит нашёл то, что пропустили пять раундов ревью:
ADR-056: nginx вёл access_log с IP и полными путями вопреки обещанию deploy.md.
Ники и социальный граф ложились в /var/log/nginx рядом с чистым журналом bare.
ADR-058: «выйти на других устройствах» не обрывал уже открытый SSE — отозванная
сессия продолжала получать сообщения.
ADR-059: промежуточный ключ комнаты был невосстановим. Участник, пропустивший
офлайн два rekey подряд, навсегда не расшифровал бы сообщения среднего ключа —
вопреки обещанию storage.md о повторной попытке после получения keyId.
ADR-063: ACK уходил по одному на конверт, а не пачкой. Получатель в оживлённой
комнате выедал общее ведро подтверждениями и упирался в 429 на всех изменяющих
запросах, включая выход из комнаты: 116 отказов за прогон стало нулём.
ADR-055, 057, 060, 061, 062: ключи вёдер и границы, +dirty у bare version,
403 unknown_device не хоронит сообщение, усечение имени в подсказке ввода,
kdf как оракул после повышения цели KDF.

Модель угроз пополнена тем, что действительно видит оператор: push-подписки
лежат в базе открытым текстом, и вместе с VAPID-ключом с той же машины это
произвольное уведомление на экране блокировки.

README приведён к v1.

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-23 06:22:52 +03:00
co-authored by Claude Opus 5
parent 0c878477d2
commit db45978b16
45 changed files with 1522 additions and 282 deletions
+19 -4
View File
@@ -7,13 +7,15 @@
export const MAX_ACK = 500;
// ApiError — ответ сервера с кодом из перечня docs/protocol.md.
// retryAfter — сколько секунд просит ждать 429; у остальных ответов null.
export class ApiError extends Error {
constructor(code, message, status, field) {
constructor(code, message, status, field, retryAfter = null) {
super(message || code);
this.name = "ApiError";
this.code = code;
this.status = status;
this.field = field;
this.retryAfter = retryAfter;
}
}
@@ -96,7 +98,19 @@ async function request(method, path, body, { quiet = false, device = null } = {}
if (code === "unauthenticated" && !quiet) {
expired();
}
throw new ApiError(code, data?.message, response.status, data?.field);
throw new ApiError(code, data?.message, response.status, data?.field, retryAfter(response));
}
// retryAfter — сколько сервер просит ждать: целые секунды, не меньше одной
// (docs/protocol.md, «Общие правила»). Заголовка нет или он не число —
// null: паузу выбирает клиент.
function retryAfter(response) {
const raw = response.headers.get("Retry-After");
if (raw === null) {
return null;
}
const seconds = Number(raw);
return Number.isInteger(seconds) && seconds > 0 ? seconds : null;
}
export function config() {
@@ -194,8 +208,9 @@ export function removeContact(nick) {
// --- комнаты -----------------------------------------------------------
// rooms — комнаты, где мы участники, каждая с нашим текущим завёрнутым
// ключом (docs/protocol.md, «Комнаты»).
// rooms — комнаты, где мы участники, каждая с нашими завёрнутыми ключами:
// сервер отдаёт все, которые ещё держит, от старого к новому (ADR-059,
// docs/protocol.md, «Комнаты»).
export function rooms() {
return request("GET", "/api/rooms");
}
+4
View File
@@ -410,6 +410,10 @@ async function changePassword(current, next, logoutOthers) {
} finally {
wipe(secret);
}
// Вход завёл новую сессию, а сессия заводится без устройства: привязку
// делает POST /api/devices. Без неё удаление этого устройства с другого
// не завершит здешнюю сессию (docs/protocol.md, «Устройства»).
await sync.rebindDevice();
}
// deleteAccount входит заново тем же порядком, что и смена пароля: сессии
+21 -5
View File
@@ -35,6 +35,11 @@ const state = {
// Вопрос идёт прямо сейчас: два сообщения подряд не должны дать
// два запроса разрешения.
asking: false,
// Браузер отказал в самой подписке: приватное окно, политика,
// недоступный push-сервис. Кнопкой это не включить, поэтому раздел
// показывает «запрещены в браузере» (ADR-046). Флаг живёт во вкладке:
// перезагрузка пробует снова — причина могла уйти.
refused: false,
};
// Приглашение установки ловится с первой секунды: браузер показывает его
@@ -91,7 +96,7 @@ function supported() {
// отклонённое разрешение, браузер без уведомлений, сервер без
// VAPID-ключа (ADR-046).
export async function notifications(key) {
if (!supported() || !key || Notification.permission === "denied") {
if (!supported() || !key || Notification.permission === "denied" || state.refused) {
return "denied";
}
if (await turnedOff()) {
@@ -288,6 +293,11 @@ async function current() {
// attach ставит подписку и отдаёт её серверу. Ключ сервера вплетён
// в подписку: сменился ключ — прежняя подписка не годится, push-сервис
// подпишет заново.
//
// Отказ самой подписки — не сбой сервера, а браузер, который её не даёт:
// приватное окно, политика, недоступный push-сервис. Кнопкой это
// не включить, поэтому false, а раздел настроек скажет «запрещены
// в браузере» и уберёт кнопку (ADR-046).
async function attach(key) {
const registration = await ready();
if (registration === null || !registration.pushManager) {
@@ -303,11 +313,17 @@ async function attach(key) {
subscription = null;
}
if (subscription === null) {
subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: unb64url(key),
});
try {
subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: unb64url(key),
});
} catch {
state.refused = true;
return false;
}
}
state.refused = false;
await put(subscription);
return true;
}
+149 -20
View File
@@ -3,10 +3,10 @@
// они не ходят — пишет в базу только этот модуль.
//
// Правила — docs/protocol.md («События», «Сообщения», «Комнаты»)
// и docs/storage.md: ACK уходит только после успешной записи в IndexedDB,
// исходящее живёт в pending до 202 и держится за свой ULID, пока время
// в нём годится серверу; отвергнутый по часам переиспользованный id
// меняется на свежий один раз (ADR-036).
// и docs/storage.md: ACK уходит пачкой и только после успешной записи
// в IndexedDB (ADR-063), исходящее живёт в pending до 202 и держится
// за свой ULID, пока время в нём годится серверу; отвергнутый по часам
// переиспользованный id меняется на свежий один раз (ADR-036).
//
// Доверие к ключам — TOFU (ADR-016): каждый публичный ключ, пришедший
// от сервера, сверяется с запомненным; изменившийся ложится в pending
@@ -39,6 +39,11 @@ import { ulid, ulidTime, validUlid } from "./ulid.js";
const RETRY_MIN = 1000;
const RETRY_MAX = 30000;
// Пауза перед отправкой подтверждений: идентификаторы копятся и уходят
// одним POST /api/ack, не чаще раза в две секунды (ADR-063). Подтверждение
// — учёт очереди сервера, а не доставка человеку: сообщение уже на экране.
const ACK_DELAY = 2000;
// Владение потоком одно на браузерный профиль: устройство у вкладок общее,
// а соединение на устройство сервер держит одно (ADR-035).
const STREAM_LOCK = "bare-stream";
@@ -88,6 +93,10 @@ const state = {
// Отложенный разбор конвертов, которые сейчас не разобрать.
inboxTimer: null,
hold: RETRY_MIN,
// Записанное в базу и ещё не подтверждённое серверу. Множество:
// конверт, выданный очередью повторно, подтверждается один раз (ADR-063).
acks: new Set(),
ackTimer: null,
};
// --- события для экранов -----------------------------------------------
@@ -320,6 +329,10 @@ export function stop() {
clearTimeout(state.inboxTimer);
state.inboxTimer = null;
}
if (state.ackTimer !== null) {
clearTimeout(state.ackTimer);
state.ackTimer = null;
}
if (state.close) {
state.close();
state.close = null;
@@ -338,6 +351,9 @@ export function stop() {
state.owed.clear();
state.pending.clear();
state.inbox.length = 0;
// Неподтверждённое не досылается: сервер выдаст эти конверты очередью
// при следующем подключении (ADR-063).
state.acks.clear();
state.wait = RETRY_MIN;
state.hold = RETRY_MIN;
setOnline(false);
@@ -393,6 +409,25 @@ async function ensureDevice() {
throw new Error("не удалось завести устройство");
}
// rebindDevice привязывает сессию к устройству заново. Сессия заводится
// без устройства (docs/storage.md, sessions.device_id), а привязывает её
// POST /api/devices. Смена пароля входит заново (ADR-031) — без этого
// новая сессия остаётся ничьей: «это устройство» в настройках не сходится,
// а DELETE /api/devices/{id} такую сессию не завершает, хотя обещает
// (docs/protocol.md, «Устройства»).
//
// Отказ ничего не ломает: привязку чинит ближайшее переподключение.
export async function rebindDevice() {
if (!state.running || state.device === null) {
return;
}
try {
await api.registerDevice(state.device);
} catch {
// Починится при следующем connect.
}
}
// --- поток событий ------------------------------------------------------
async function connect() {
@@ -408,6 +443,15 @@ async function connect() {
// Запрос не дошёл — это и есть «нет соединения» (ADR-028).
setOnline(false);
}
if (rateLimited(err)) {
// Ведро изменяющих запросов общее на пользователя (ADR-055):
// устройство могло не завестись из-за соседнего устройства или
// прежней работы этой же вкладки. Это задержка, а не отказ —
// без устройства нет ни потока, ни отправки, и сама вкладка
// не ожила бы до перезагрузки.
retryLater(pause(err));
return;
}
if (transient(err)) {
retryLater();
}
@@ -452,6 +496,13 @@ function claimStream() {
});
}
// owner — держит ли эта вкладка поток событий. Без BroadcastChannel или
// navigator.locks арбитража нет вовсе, и вкладка работает как единственная
// (ADR-035), поэтому владельцем считается и она.
function owner() {
return !shared || state.release !== null;
}
// yieldStream отпускает владение: соседняя вкладка займёт поток сразу.
function yieldStream() {
if (state.claim) {
@@ -502,12 +553,20 @@ function openStream() {
});
}
function retryLater() {
// retryLater откладывает восстановление. Без аргумента пауза своя
// и удваивается; after — пауза, которую назвал сервер (Retry-After),
// и очередь удвоений она не двигает: отказ по частоте не значит, что
// поток нездоров. Дольше RETRY_MAX не ждём и в этом случае.
function retryLater(after = null) {
if (state.timer !== null || !state.running) {
return;
}
const delay = state.wait;
state.wait = Math.min(delay * 2, RETRY_MAX);
let delay = state.wait;
if (after === null) {
state.wait = Math.min(delay * 2, RETRY_MAX);
} else {
delay = Math.min(after, RETRY_MAX);
}
state.timer = setTimeout(() => {
state.timer = null;
recover();
@@ -625,7 +684,7 @@ async function flush() {
state.hold = RETRY_MIN;
}
// ACK — только после успешной записи (docs/storage.md).
await ackAll(acked);
ackLater(acked);
}
// postpone откладывает повторный разбор: причина, по которой конверт не
@@ -752,7 +811,32 @@ async function reopen() {
notify(messages);
}
async function ackAll(ids) {
// ackLater копит подтверждения и отправляет их пачкой. По запросу
// на конверт получатель оживлённой комнаты тратил общее ведро одними
// подтверждениями и упирался в 429 на всём изменяющем — включая выход
// из комнаты и смену пароля (ADR-063). Правило docs/storage.md остаётся
// дословным: сюда попадает только то, что уже записано в IndexedDB.
function ackLater(ids) {
for (const id of ids) {
state.acks.add(id);
}
if (state.acks.size === 0 || state.ackTimer !== null || !state.running) {
return;
}
state.ackTimer = setTimeout(() => {
state.ackTimer = null;
ackNow();
}, ACK_DELAY);
}
// ackNow отдаёт накопленное. Больше MAX_ACK за раз сервер не принимает,
// поэтому длинная пачка идёт кусками.
async function ackNow() {
const ids = [...state.acks];
state.acks.clear();
if (ids.length === 0 || !state.running) {
return;
}
for (let i = 0; i < ids.length; i += api.MAX_ACK) {
try {
await api.ack(state.device, ids.slice(i, i + api.MAX_ACK));
@@ -800,6 +884,18 @@ async function refreshContacts() {
}
}
// resumePending повторяет неотправленное там, где это законно: повтор
// принадлежит владельцу потока, иначе одно сообщение ушло бы дважды,
// с разными ULID (ADR-035). Невладеющая вкладка отдаёт свой список
// владельцу — он повторит его после ближайшего ready.
async function resumePending() {
if (owner()) {
await retryPending();
return;
}
share({ kind: "pending", ids: [...state.pending] });
}
// retryPending повторяет неотправленное после подключения. Идёт прямо,
// без serial: afterReady уже внутри очереди.
async function retryPending() {
@@ -912,7 +1008,9 @@ export function trustKey(nick) {
// Владелец, чей rekey упирался в этот ключ, доводит его до конца.
await payRekeys();
await reopen();
await retryPending();
// «Доверять новому ключу» нажимают в любой вкладке, а повторяет
// неотправленное владелец потока (ADR-035).
await resumePending();
return true;
});
}
@@ -945,7 +1043,7 @@ function usableRoom(r) {
&& typeof r.owner === "string"
&& Array.isArray(r.members) && r.members.every((nick) => typeof nick === "string")
&& Number.isFinite(r.createdAt)
&& (r.key === null || r.key === undefined || usableKey(r.key));
&& Array.isArray(r.keys) && r.keys.every(usableKey);
}
function usableKey(k) {
@@ -1006,10 +1104,15 @@ async function senderKey(nick) {
return record.pending ? null : record.publicKey;
}
// takeRoomKey разворачивает завёрнутый нам ключ комнаты и кладёт его
// takeRoomKeys разворачивает завёрнутые нам ключи комнаты и кладёт их
// в roomKeys вместе с from и receivedAt (docs/crypto.md, «Комната»).
// Отдаёт, появился ли новый ключ.
//
// Ключей бывает несколько: `GET /api/rooms` отдаёт все, которые сервер
// ещё держит, — участник, пропустивший rekey в офлайне, добирает отсюда
// недостающий keyId и читает конверт, пришедший с ним (ADR-059).
// Событие room несёт один ключ, новый.
//
// Уже известный keyId не трогается: клиент держит все ключи комнаты.
// Не развернувшийся не теряется — сервер отдаёт его снова с каждым
// GET /api/rooms.
@@ -1019,9 +1122,20 @@ async function senderKey(nick) {
// до запроса его публичного ключа: TOFU запоминает первый ключ молча,
// поэтому незнакомый распространитель — это подмена, а не первый
// контакт (ADR-039).
async function takeRoomKey(room) {
const wrapped = room.key;
if (!usableKey(wrapped) || !room.members.includes(wrapped.from)) {
async function takeRoomKeys(room) {
let fresh = false;
// Порядок — от старого ключа к новому: текущим у нас становится
// последний сохранённый (ADR-042).
for (const wrapped of room.keys) {
if (await takeRoomKey(room, wrapped)) {
fresh = true;
}
}
return fresh;
}
async function takeRoomKey(room, wrapped) {
if (!room.members.includes(wrapped.from)) {
return false;
}
if (await db.roomKey(room.id, wrapped.keyId)) {
@@ -1069,7 +1183,7 @@ async function applyRoom(room) {
return;
}
const changed = await saveRoom(room);
const fresh = await takeRoomKey(room);
const fresh = await takeRoomKeys(room);
if (changed) {
announceChats();
}
@@ -1115,7 +1229,7 @@ async function refreshRooms() {
}
seen.add(room.id);
const moved = await saveRoom(room);
const key = await takeRoomKey(room);
const key = await takeRoomKeys(room);
changed = changed || moved;
fresh = fresh || key;
if (moved || key) {
@@ -1335,7 +1449,9 @@ async function changeRoom(roomId, add, remove) {
announceRoom(roomId);
if (stored) {
await reopen();
await retryPending();
// Состав меняют из любой вкладки; повтор неотправленного — дело
// владельца потока (ADR-035).
await resumePending();
}
return room;
}
@@ -1567,8 +1683,9 @@ async function post(message, peer, roomId) {
}
// settle разбирает отказ. Сеть и 500 сообщение не хоронят: оно остаётся
// pending и повторится при следующем подключении (ADR-027). Удалённое
// устройство чинится тем же способом — переподключением. Остальные 4xx —
// pending и повторится при следующем подключении (ADR-027). Потерянное
// устройство — то же самое: это не отказ сообщению, и чинится он
// переподключением, а не текстом в ленте (ADR-060). Остальные 4xx —
// failed с текстом отказа (ADR-033).
async function settle(message, err) {
if (err instanceof NetworkError) {
@@ -1600,6 +1717,18 @@ function transient(err) {
|| (err instanceof ApiError && err.status >= 500);
}
// rateLimited — 429: сервер ответил и просит подождать (ADR-055).
// Сообщению это отказ — «слишком часто, попробуйте позже» и failed
// (docs/storage.md), а устройству и потоку — всего лишь задержка.
function rateLimited(err) {
return err instanceof ApiError && err.code === "rate_limited";
}
// pause — пауза из Retry-After в миллисекундах; заголовка не было — null.
function pause(err) {
return typeof err.retryAfter === "number" ? err.retryAfter * 1000 : null;
}
// --- действия экранов ---------------------------------------------------
// openDm заводит личный чат с ником и отдаёт chatId. Строку списка