Сервер: регистрация устройств и 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
3.5 KiB
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, больше не двигает счётчик непрочитанных.