Files
bare/docs/decisions/034-incoming-message-id.md
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

3.5 KiB
Raw Permalink Blame History

ADR-034: Входящее сообщение с известным id не перезаписывает запись

Контекст

docs/storage.md описывал повтор доставки одной строкой: «put с тем же id, без дублей». Подразумевался тот же самый конверт — сервер выдаёт очередь заново при каждом подключении и вправе прислать конверт дважды (ADR-017). Клиент так и делал: писал put по id безусловно.

id — открытое поле конверта, собеседник видит его сразу, как получает сообщение. Истории идентификаторов сервер не хранит: это противоречило бы ADR-008, — поэтому POST /api/messages с чужим id он принимает и раскладывает по очередям как любой другой. AAD сходится: в него входят id, chat, from и keyId, а from сервер ставит из сессии — конверт собеседника валиден и расшифровывается. Дальше put перезаписывал мою строку: в ленте вместо моего сообщения оказывался чужой текст, исходное исчезало, и фан-аут разносил подмену на остальные мои устройства. Тем же приёмом собеседник стирал и то, что прислал сам, — это прямо противоречит модели угроз: «„Удалить у всех“ после доставки не существует».

Вторая половина того же места — счётчик. Все чтения messages выпускались до первого put, поэтому две копии одного конверта в одной пачке обе считались новыми и unread рос дважды. Такая пачка — не гипотеза: конверт, попавший в очередь в момент подключения, приходит и выдачей очереди, и живым событием.

Решение

  • Входящее сообщение с уже известным id игнорируется целиком: ни записи, ни счётчика непрочитанных. Строку, которая уже лежит на устройстве, входящий конверт не трогает.
  • Повторный id внутри одной пачки учитывается один раз.
  • Перезапись по id остаётся у исходящего: переход pending → sent/failed, где id свой и запись своя.
  • Правило записано строкой в docs/storage.md.

Следствия

  • Знание id не даёт собеседнику ничего: подменяющий конверт у получателя молча пропадает.
  • Нерасшифрованное чинится не повторной доставкой, а полем raw — оно для этого и хранится (docs/storage.md).
  • Дубль доставки, разрешённый ADR-017, больше не двигает счётчик непрочитанных.