Этап 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:
+19
-4
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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. Строку списка
|
||||
|
||||
Reference in New Issue
Block a user